diff --git a/redisinsight/ui/src/components/global-dialogs/GlobalDialogs.tsx b/redisinsight/ui/src/components/global-dialogs/GlobalDialogs.tsx index b86eb40f5f..56275caa7d 100644 --- a/redisinsight/ui/src/components/global-dialogs/GlobalDialogs.tsx +++ b/redisinsight/ui/src/components/global-dialogs/GlobalDialogs.tsx @@ -4,6 +4,7 @@ import { OAuthSelectAccountDialog, OAuthSelectPlan, OAuthSignInDialog, + OAuthSocialDialog, } from 'uiSrc/components' import { FeatureFlags } from 'uiSrc/constants' @@ -13,6 +14,7 @@ const GlobalDialogs = () => ( + ) diff --git a/redisinsight/ui/src/components/notifications/Notifications.tsx b/redisinsight/ui/src/components/notifications/Notifications.tsx index 4582d8ee11..800bba70ca 100644 --- a/redisinsight/ui/src/components/notifications/Notifications.tsx +++ b/redisinsight/ui/src/components/notifications/Notifications.tsx @@ -23,6 +23,7 @@ import { ApiEncryptionErrors } from 'uiSrc/constants/apiErrors' import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/utils' import { showOAuthProgress } from 'uiSrc/slices/oauth/cloud' +import { CustomErrorCodes } from 'uiSrc/constants' import errorMessages from './error-messages' import { InfiniteMessagesIds } from './components' @@ -90,10 +91,18 @@ const Notifications = () => { }) const getErrorsToasts = (errors: IError[]) => - errors.map(({ id = '', message = DEFAULT_ERROR_MESSAGE, instanceId = '', name, title }) => { + errors.map(({ id = '', message = DEFAULT_ERROR_MESSAGE, instanceId = '', name, title, additionalInfo }) => { if (ApiEncryptionErrors.includes(name)) { return errorMessages.ENCRYPTION(id, () => removeToast({ id }), instanceId) } + + if (additionalInfo?.errorCode === CustomErrorCodes.CloudCapiKeyUnauthorized) { + return errorMessages.CLOUD_CAPI_KEY_UNAUTHORIZED( + { id, message, title }, + additionalInfo, + () => removeToast({ id }) + ) + } return errorMessages.DEFAULT(id, message, () => removeToast({ id }), title) }) diff --git a/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/CloudCapiUnAuthorizedErrorContent.spec.tsx b/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/CloudCapiUnAuthorizedErrorContent.spec.tsx new file mode 100644 index 0000000000..b50fc9bb03 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/CloudCapiUnAuthorizedErrorContent.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import reactRouterDom from 'react-router-dom' +import { render, screen, fireEvent, mockedStore, cleanup, act } from 'uiSrc/utils/test-utils' + +import { removeCapiKey } from 'uiSrc/slices/oauth/cloud' +import { apiService } from 'uiSrc/services' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' + +import CloudCapiUnAuthorizedErrorContent, { Props } from './CloudCapiUnAuthorizedErrorContent' + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn, + }), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const mockedProps = mock() +describe('CloudCapiUnAuthorizedErrorContent', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should сall proper action on delete', () => { + const onClose = jest.fn() + render() + + fireEvent.click(screen.getByTestId('remove-api-key-btn')) + + expect(store.getActions()).toEqual([removeCapiKey()]) + expect(onClose).toBeCalled() + }) + + it('should сall proper history push on go to settings', () => { + const pushMock = jest.fn() + const onClose = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }) + render() + + fireEvent.click(screen.getByTestId('go-to-settings-btn')) + + expect(pushMock).toBeCalledWith('/settings#cloud') + expect(onClose).toBeCalled() + }) + + it('should сall proper telemetry on delete', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + apiService.delete = jest.fn().mockResolvedValueOnce({ status: 200 }) + + render() + + await act(() => { + fireEvent.click(screen.getByTestId('remove-api-key-btn')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_API_KEY_REMOVED, + eventData: { + source: OAuthSocialSource.ConfirmationMessage + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) +}) diff --git a/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/CloudCapiUnAuthorizedErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/CloudCapiUnAuthorizedErrorContent.tsx new file mode 100644 index 0000000000..7c69713cd5 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/CloudCapiUnAuthorizedErrorContent.tsx @@ -0,0 +1,76 @@ +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTextColor } from '@elastic/eui' +import React from 'react' +import { useDispatch } from 'react-redux' +import { useHistory } from 'react-router-dom' +import { removeCapiKeyAction } from 'uiSrc/slices/oauth/cloud' +import { Pages } from 'uiSrc/constants' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +export interface Props { + resourceId: string + text: string | JSX.Element | JSX.Element[] + onClose?: () => void +} + +const CloudCapiUnAuthorizedErrorContent = ( + { + text, + onClose = () => {}, + resourceId, + }: Props +) => { + const dispatch = useDispatch() + const history = useHistory() + + const handleRemoveCapi = () => { + dispatch(removeCapiKeyAction({ id: resourceId, name: 'Api Key' }, () => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_API_KEY_REMOVED, + eventData: { + source: OAuthSocialSource.ConfirmationMessage + } + }) + })) + onClose?.() + } + + const handleGoToSettings = () => { + history.push(`${Pages.settings}#cloud`) + onClose?.() + } + + return ( + <> + {text} + + + + + Go to Settings + + + + + Remove API key + + + + + ) +} + +export default CloudCapiUnAuthorizedErrorContent diff --git a/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/index.ts b/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/index.ts new file mode 100644 index 0000000000..adba14610c --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/cloud-capi-unauthorized/index.ts @@ -0,0 +1,3 @@ +import CloudCapiUnAuthorizedErrorContent from './CloudCapiUnAuthorizedErrorContent' + +export default CloudCapiUnAuthorizedErrorContent diff --git a/redisinsight/ui/src/components/notifications/components/DefaultErrorContent.spec.tsx b/redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/notifications/components/DefaultErrorContent.spec.tsx rename to redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.spec.tsx diff --git a/redisinsight/ui/src/components/notifications/components/DefaultErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.tsx similarity index 100% rename from redisinsight/ui/src/components/notifications/components/DefaultErrorContent.tsx rename to redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.tsx diff --git a/redisinsight/ui/src/components/notifications/components/default-error-content/index.ts b/redisinsight/ui/src/components/notifications/components/default-error-content/index.ts new file mode 100644 index 0000000000..99b4bdb31f --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/default-error-content/index.ts @@ -0,0 +1,3 @@ +import DefaultErrorContent from './DefaultErrorContent' + +export default DefaultErrorContent diff --git a/redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.spec.tsx b/redisinsight/ui/src/components/notifications/components/encryption-error-content/EncryptionErrorContent.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.spec.tsx rename to redisinsight/ui/src/components/notifications/components/encryption-error-content/EncryptionErrorContent.spec.tsx diff --git a/redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/encryption-error-content/EncryptionErrorContent.tsx similarity index 100% rename from redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.tsx rename to redisinsight/ui/src/components/notifications/components/encryption-error-content/EncryptionErrorContent.tsx diff --git a/redisinsight/ui/src/components/notifications/components/encryption-error-content/index.ts b/redisinsight/ui/src/components/notifications/components/encryption-error-content/index.ts new file mode 100644 index 0000000000..4820f655f7 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/encryption-error-content/index.ts @@ -0,0 +1,3 @@ +import EncryptionErrorContent from './EncryptionErrorContent' + +export default EncryptionErrorContent diff --git a/redisinsight/ui/src/components/notifications/components/index.ts b/redisinsight/ui/src/components/notifications/components/index.ts index 108f09a520..35b12897b3 100644 --- a/redisinsight/ui/src/components/notifications/components/index.ts +++ b/redisinsight/ui/src/components/notifications/components/index.ts @@ -1,6 +1,6 @@ -import DefaultErrorContent from './DefaultErrorContent' -import EncryptionErrorContent from './EncryptionErrorContent' -import { INFINITE_MESSAGES, InfiniteMessagesIds } from './InfiniteMessages' +import DefaultErrorContent from './default-error-content' +import EncryptionErrorContent from './encryption-error-content' +import { INFINITE_MESSAGES, InfiniteMessagesIds } from './infinite-messages' export { EncryptionErrorContent, diff --git a/redisinsight/ui/src/components/notifications/components/InfiniteMessages.spec.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/notifications/components/InfiniteMessages.spec.tsx rename to redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.spec.tsx diff --git a/redisinsight/ui/src/components/notifications/components/InfiniteMessages.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx similarity index 100% rename from redisinsight/ui/src/components/notifications/components/InfiniteMessages.tsx rename to redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx diff --git a/redisinsight/ui/src/components/notifications/components/infinite-messages/index.ts b/redisinsight/ui/src/components/notifications/components/infinite-messages/index.ts new file mode 100644 index 0000000000..679a64ac0b --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/index.ts @@ -0,0 +1,3 @@ +import { InfiniteMessagesIds, INFINITE_MESSAGES } from './InfiniteMessages' + +export { InfiniteMessagesIds, INFINITE_MESSAGES } diff --git a/redisinsight/ui/src/components/notifications/error-messages.tsx b/redisinsight/ui/src/components/notifications/error-messages.tsx index e5c9a9d0cf..87de57da45 100644 --- a/redisinsight/ui/src/components/notifications/error-messages.tsx +++ b/redisinsight/ui/src/components/notifications/error-messages.tsx @@ -2,6 +2,7 @@ import React from 'react' import { EuiTextColor } from '@elastic/eui' import { Toast } from '@elastic/eui/src/components/toast/global_toast_list' import { EncryptionErrorContent, DefaultErrorContent } from './components' +import CloudCapiUnAuthorizedErrorContent from './components/cloud-capi-unauthorized' // TODO: use i18n file for texts export default { @@ -32,4 +33,31 @@ export default { ), text: , }), + CLOUD_CAPI_KEY_UNAUTHORIZED: ( + { id, message, title }: { + id: string, + message: string | JSX.Element, + title?: string + }, + additionalInfo: Record, + onClose?: () => void + ): Toast => ({ + id, + 'data-test-subj': 'toast-error-cloud-capi-key-unauthorized', + color: 'danger', + iconType: 'alert', + onClose, + title: ( + + {title} + + ), + text: ( + + ), + }) } diff --git a/redisinsight/ui/src/components/notifications/index.ts b/redisinsight/ui/src/components/notifications/index.ts index c242b9537b..0ae6e8788c 100644 --- a/redisinsight/ui/src/components/notifications/index.ts +++ b/redisinsight/ui/src/components/notifications/index.ts @@ -1 +1,3 @@ -export * from './Notifications' +import Notifications from './Notifications' + +export default Notifications diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index 2f3c7b353f..1f9b1dd5b5 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -214,5 +214,13 @@ export default { has been added. ), + }), + REMOVED_ALL_CAPI_KEYS: () => ({ + title: 'API keys have been removed', + message: 'All API keys have been removed from RedisInsight.', + }), + REMOVED_CAPI_KEY: (name: string) => ({ + title: 'API Key has been removed', + message: `${formatNameShort(name)} has been removed from RedisInsight.` }) } diff --git a/redisinsight/ui/src/components/oauth/index.ts b/redisinsight/ui/src/components/oauth/index.ts index 827e2aa5b2..a97be27198 100644 --- a/redisinsight/ui/src/components/oauth/index.ts +++ b/redisinsight/ui/src/components/oauth/index.ts @@ -1,6 +1,7 @@ import OAuthSignInDialog from './oauth-sign-in-dialog' import OAuthSocial, { OAuthSocialType } from './oauth-social' import OAuthSocialDialog from './oauth-social-dialog' +import OAuthSocialHandlerDialog from './oauth-social-handler-dialog/OAuthSocialHandlerDialog' import OAuthSsoHandlerDialog from './oauth-sso-handler-dialog/OAuthSsoHandlerDialog' import OAuthSelectAccountDialog from './oauth-select-account-dialog/OAuthSelectAccountDialog' import OAuthConnectFreeDb from './oauth-connect-free-db' @@ -11,6 +12,7 @@ export { OAuthSignInDialog, OAuthSocial, OAuthSocialDialog, + OAuthSocialHandlerDialog, OAuthSsoHandlerDialog, OAuthSelectAccountDialog, OAuthConnectFreeDb, diff --git a/redisinsight/ui/src/components/oauth/oauth-social-dialog/OAuthSocialDialog.tsx b/redisinsight/ui/src/components/oauth/oauth-social-dialog/OAuthSocialDialog.tsx index 2393e0f866..6a7a870949 100644 --- a/redisinsight/ui/src/components/oauth/oauth-social-dialog/OAuthSocialDialog.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-social-dialog/OAuthSocialDialog.tsx @@ -1,20 +1,32 @@ import React from 'react' import { EuiModal, EuiModalBody } from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' import OAuthSocial, { OAuthSocialType } from 'uiSrc/components/oauth/oauth-social/OAuthSocial' +import { oauthCloudSelector, setSocialDialogState } from 'uiSrc/slices/oauth/cloud' import styles from './styles.module.scss' -export interface Props { - onClose: () => void -} +const OAuthSocialDialog = () => { + const { isOpenSocialDialog } = useSelector(oauthCloudSelector) + + const dispatch = useDispatch() + + const handleClose = () => { + dispatch(setSocialDialogState(null)) + } -const OAuthSocialDialog = ({ onClose }: Props) => ( - onClose?.()} className={styles.modal} data-testid="social-oauth-dialog"> - - - - -) + if (!isOpenSocialDialog) { + return null + } + + return ( + + + + + + ) +} export default OAuthSocialDialog diff --git a/redisinsight/ui/src/components/oauth/oauth-social-handler-dialog/OAuthSocialHandlerDialog.spec.tsx b/redisinsight/ui/src/components/oauth/oauth-social-handler-dialog/OAuthSocialHandlerDialog.spec.tsx new file mode 100644 index 0000000000..b2c399a38a --- /dev/null +++ b/redisinsight/ui/src/components/oauth/oauth-social-handler-dialog/OAuthSocialHandlerDialog.spec.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render } from 'uiSrc/utils/test-utils' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import { FeatureFlags } from 'uiSrc/constants' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import OAuthSocialHandlerDialog from './OAuthSocialHandlerDialog' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/slices/oauth/cloud', () => ({ + ...jest.requireActual('uiSrc/slices/oauth/cloud'), + oauthCloudUserDataSelector: jest.fn().mockReturnValue(null), +})) + +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + cloudSso: { + flag: false, + } + }), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const childrenMock = ( + onClick: (e: React.MouseEvent, source: OAuthSocialSource) => void, + source: OAuthSocialSource, +) => ( +
onClick(e, source)} + onKeyDown={() => {}} + data-testid="link" + aria-label="link" + role="link" + tabIndex={0} + > + link +
+) + +afterEach(() => { + (sendEventTelemetry as jest.Mock).mockRestore() +}) + +describe('OAuthSocialHandlerDialog', () => { + it('should render', () => { + expect(render( + + {(ssoCloudHandlerClick) => (childrenMock(ssoCloudHandlerClick, OAuthSocialSource.BrowserContentMenu))} + + )).toBeTruthy() + }) + + it(`setSignInDialogState should not called if ${FeatureFlags.cloudSso} is not enabled`, () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + render( + + {(ssoCloudHandlerClick) => (childrenMock(ssoCloudHandlerClick, OAuthSocialSource.BrowserContentMenu))} + + ) + + expect(sendEventTelemetry).not.toBeCalled() + expect(store.getActions()).toEqual([]) + }) + + it(`setSignInDialogState should called if ${FeatureFlags.cloudSso} is enabled`, () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock); + + (appFeatureFlagsFeaturesSelector as jest.Mock).mockImplementation(() => ( + { [FeatureFlags.cloudSso]: { flag: true } } + )) + + const { queryByTestId } = render( + + {(ssoCloudHandlerClick) => (childrenMock(ssoCloudHandlerClick, OAuthSocialSource.BrowserContentMenu))} + + ) + + fireEvent.click(queryByTestId('link') as HTMLElement) + + const expectedActions = [setSocialDialogState(OAuthSocialSource.BrowserContentMenu)] + expect(store.getActions()).toEqual(expectedActions) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_IMPORT_DATABASES_CLICKED, + eventData: { + source: OAuthSocialSource.BrowserContentMenu + } + }) + }) +}) diff --git a/redisinsight/ui/src/components/oauth/oauth-social-handler-dialog/OAuthSocialHandlerDialog.tsx b/redisinsight/ui/src/components/oauth/oauth-social-handler-dialog/OAuthSocialHandlerDialog.tsx new file mode 100644 index 0000000000..d61d915de6 --- /dev/null +++ b/redisinsight/ui/src/components/oauth/oauth-social-handler-dialog/OAuthSocialHandlerDialog.tsx @@ -0,0 +1,38 @@ +import { useDispatch, useSelector } from 'react-redux' +import React from 'react' + +import { FeatureFlags } from 'uiSrc/constants' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' + +export interface Props { + children: (socialCloudHandlerClick: (e: React.MouseEvent, source: OAuthSocialSource) => void) => React.ReactElement +} + +const OAuthSocialHandlerDialog = ({ children }: Props) => { + const { [FeatureFlags.cloudSso]: feature } = useSelector(appFeatureFlagsFeaturesSelector) + + const dispatch = useDispatch() + + const socialCloudHandlerClick = (e: React.MouseEvent, source: OAuthSocialSource) => { + const isCloudSsoEnabled = !!feature?.flag + + if (!isCloudSsoEnabled) { + return + } + e?.preventDefault() + + dispatch(setSocialDialogState?.(source)) + + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_IMPORT_DATABASES_CLICKED, + eventData: { source }, + }) + } + + return children?.(socialCloudHandlerClick) +} + +export default OAuthSocialHandlerDialog diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 0252893aa5..847f81a533 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -130,6 +130,7 @@ enum ApiEndpoints { CLOUD_ME_AUTODISCOVERY_SUBSCRIPTIONS = 'cloud/me/autodiscovery/subscriptions', CLOUD_ME_AUTODISCOVERY_GET_DATABASES = 'cloud/me/autodiscovery/get-databases', CLOUD_ME_AUTODISCOVERY_DATABASES = 'cloud/me/autodiscovery/databases', + CLOUD_CAPI_KEYS = 'cloud/me/capi-keys', } export enum CustomHeaders { diff --git a/redisinsight/ui/src/constants/customErrorCodes.ts b/redisinsight/ui/src/constants/customErrorCodes.ts index d379b58ec9..277b91395d 100644 --- a/redisinsight/ui/src/constants/customErrorCodes.ts +++ b/redisinsight/ui/src/constants/customErrorCodes.ts @@ -12,6 +12,8 @@ export enum CustomErrorCodes { CloudOauthGithubEmailPermission = 11_006, CloudOauthUnknownAuthorizationRequest = 11_007, CloudOauthUnexpectedError = 11_008, + CloudCapiUnauthorized = 11_021, + CloudCapiKeyUnauthorized = 11_022, // Cloud Job errors [11100, 11199] CloudJobUnexpectedError = 11_100, diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx index b678e0d888..566f25af33 100644 --- a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx @@ -1,9 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { cloneDeep } from 'lodash' +import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import { MOCKED_CREATE_REDIS_BTN_CONTENT } from 'uiSrc/mocks/content/content' import { AddDbType } from 'uiSrc/pages/home/components/AddDatabases/AddDatabasesContainer' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' import WelcomeComponent, { Props } from './WelcomeComponent' jest.mock('uiSrc/slices/content/create-redis-buttons', () => ({ @@ -13,6 +16,13 @@ jest.mock('uiSrc/slices/content/create-redis-buttons', () => ({ const mockedProps = mock() +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + describe('WelcomeComponent', () => { it('should render', () => { expect( @@ -59,11 +69,11 @@ describe('WelcomeComponent', () => { expect(screen.getByTestId('import-dbs-dialog')).toBeInTheDocument() }) - it('should open social oauth dialog', () => { + it('should call open social oauth dialog', () => { render() fireEvent.click(screen.getByTestId('import-cloud-db-btn')) - expect(screen.getByTestId('social-oauth-dialog')).toBeInTheDocument() + expect(store.getActions()).toEqual([setSocialDialogState(OAuthSocialSource.WelcomeScreen)]) }) }) diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx index 26a15cc345..83617ef099 100644 --- a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx +++ b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx @@ -1,6 +1,6 @@ import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiSpacer, EuiFlexGrid } from '@elastic/eui' import React, { useContext, useEffect, useState } from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { isEmpty } from 'lodash' @@ -20,10 +20,11 @@ import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import { getContentByFeature } from 'uiSrc/utils/content' import { HELP_LINKS, IHelpGuide } from 'uiSrc/pages/home/constants/help-links' import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' -import { ImportDatabasesDialog, OAuthSocialDialog, OAuthSsoHandlerDialog } from 'uiSrc/components' +import { ImportDatabasesDialog, OAuthSsoHandlerDialog } from 'uiSrc/components' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' import { getPathToResource } from 'uiSrc/services/resourcesService' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' import styles from './styles.module.scss' export interface Props { @@ -38,8 +39,8 @@ const Welcome = ({ onAddInstance }: Props) => { const [promoData, setPromoData] = useState() const [guides, setGuides] = useState([]) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) - const [isOauthSocialOpen, setIsOauthSocialOpen] = useState(false) + const dispatch = useDispatch() const { theme } = useContext(ThemeContext) setTitle('Welcome to RedisInsight') @@ -72,7 +73,9 @@ const Welcome = ({ onAddInstance }: Props) => { description: 'Sign in to your Redis Enterprise Cloud account to discover and add databases', iconType: CloudIcon, iconClassName: styles.cloudIcon, - onClick: () => setIsOauthSocialOpen(true), + onClick: () => { + dispatch(setSocialDialogState(OAuthSocialSource.WelcomeScreen)) + }, testId: 'import-cloud-db-btn' }, { @@ -179,7 +182,6 @@ const Welcome = ({ onAddInstance }: Props) => { return ( <> {isImportDialogOpen && } - {isOauthSocialOpen && setIsOauthSocialOpen(false)} />}
diff --git a/redisinsight/ui/src/pages/settings/SettingsPage.spec.tsx b/redisinsight/ui/src/pages/settings/SettingsPage.spec.tsx index c6dd89ebe9..cdb7bc5fe9 100644 --- a/redisinsight/ui/src/pages/settings/SettingsPage.spec.tsx +++ b/redisinsight/ui/src/pages/settings/SettingsPage.spec.tsx @@ -1,6 +1,7 @@ import React from 'react' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import SettingsPage from './SettingsPage' jest.mock('uiSrc/telemetry', () => ({ @@ -8,6 +9,15 @@ jest.mock('uiSrc/telemetry', () => ({ sendEventTelemetry: jest.fn(), })) +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + cloudSso: { + flag: false + } + }), +})) + /** * SettingsPage tests * @@ -51,6 +61,29 @@ describe('SettingsPage', () => { ).toBeInTheDocument() expect(render()).toBeTruthy() }) + + it('should not render cloud accordion without feature flag', () => { + const { container } = render() + + expect( + container.querySelector('[data-test-subj="accordion-cloud-settings"]') + ).not.toBeInTheDocument() + expect(render()).toBeTruthy() + }) + + it('should render cloud accordion with feature flag', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValue({ + cloudSso: { + flag: true + } + }) + const { container } = render() + + expect( + container.querySelector('[data-test-subj="accordion-cloud-settings"]') + ).toBeInTheDocument() + expect(render()).toBeTruthy() + }) }) describe('Telemetry', () => { diff --git a/redisinsight/ui/src/pages/settings/SettingsPage.tsx b/redisinsight/ui/src/pages/settings/SettingsPage.tsx index 2d9af00b67..23766ed849 100644 --- a/redisinsight/ui/src/pages/settings/SettingsPage.tsx +++ b/redisinsight/ui/src/pages/settings/SettingsPage.tsx @@ -18,9 +18,9 @@ import { import { useDispatch, useSelector } from 'react-redux' import { setTitle } from 'uiSrc/utils' -import { THEMES } from 'uiSrc/constants' +import { FeatureFlags, THEMES } from 'uiSrc/constants' import { useDebouncedEffect } from 'uiSrc/services' -import { ConsentsNotifications, ConsentsPrivacy } from 'uiSrc/components' +import { ConsentsNotifications, ConsentsPrivacy, FeatureFlagComponent } from 'uiSrc/components' import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' import { ThemeContext } from 'uiSrc/contexts/themeContext' @@ -30,19 +30,23 @@ import { userSettingsSelector, } from 'uiSrc/slices/user/user-settings' -import { AdvancedSettings, WorkbenchSettings } from './components' +import { AdvancedSettings, WorkbenchSettings, CloudSettings } from './components' import styles from './styles.module.scss' const SettingsPage = () => { - const options = THEMES - const themeContext = useContext(ThemeContext) - const [loading, setLoading] = useState(false) const { loading: settingsLoading } = useSelector(userSettingsSelector) const { identified: analyticsIdentified } = useSelector(appAnalyticsInfoSelector) + const initialOpenSection = globalThis.location.hash || '' + const dispatch = useDispatch() + + const options = THEMES + const themeContext = useContext(ThemeContext) + const { theme, changeTheme } = themeContext + useEffect(() => { // componentDidMount // fetch config settings, after that take spec @@ -61,8 +65,8 @@ const SettingsPage = () => { setTitle('Settings') const onChange = (value: string) => { - const previousValue = themeContext.theme - themeContext.changeTheme(value) + const previousValue = theme + changeTheme(value) sendEventTelemetry({ event: TelemetryEvent.SETTINGS_COLOR_THEME_CHANGED, eventData: { @@ -82,7 +86,7 @@ const SettingsPage = () => { {
) + const CloudSettingsGroup = () => ( +
+ {loading && ( +
+ +
+ )} + +
+ ) + const AdvancedSettingsGroup = () => (
{loading && ( @@ -145,7 +160,7 @@ const SettingsPage = () => { isCollapsible className={styles.accordion} title="General" - initialIsOpen={false} + initialIsOpen={initialOpenSection === '#general'} data-test-subj="accordion-appearance" > {Appearance()} @@ -154,7 +169,7 @@ const SettingsPage = () => { isCollapsible className={styles.accordion} title="Privacy" - initialIsOpen={false} + initialIsOpen={initialOpenSection === '#privacy'} data-test-subj="accordion-privacy-settings" > {PrivacySettings()} @@ -163,16 +178,27 @@ const SettingsPage = () => { isCollapsible className={styles.accordion} title="Workbench" - initialIsOpen={false} + initialIsOpen={initialOpenSection === '#workbench'} data-test-subj="accordion-workbench-settings" > {WorkbenchSettingsGroup()} + + + {CloudSettingsGroup()} + + {AdvancedSettingsGroup()} diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.spec.tsx b/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.spec.tsx new file mode 100644 index 0000000000..697f95291d --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.spec.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { act, cleanup, fireEvent, mockedStore, render, screen, waitForEuiPopoverVisible } from 'uiSrc/utils/test-utils' + +import { getCapiKeys, oauthCapiKeysSelector, removeAllCapiKeys } from 'uiSrc/slices/oauth/cloud' +import { apiService } from 'uiSrc/services' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import CloudSettings from './CloudSettings' + +jest.mock('uiSrc/slices/oauth/cloud', () => ({ + ...jest.requireActual('uiSrc/slices/oauth/cloud'), + oauthCapiKeysSelector: jest.fn().mockReturnValue({ + data: null, + loading: false + }), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +describe('CloudSettings', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should get api keys after render', () => { + render() + + expect(store.getActions()).toEqual([getCapiKeys()]) + }) + + it('should be disabled delete all button', () => { + render() + + expect(screen.getByTestId('delete-key-btn')).toBeDisabled() + }) + + it('should show delete popover and call proper action on delete', async () => { + (oauthCapiKeysSelector as jest.Mock).mockReturnValue({ + data: [ + { + id: '1', + name: 'RedisInsight-f4868252-a128-4a02-af75-bd3c99898267-2020-11-01T-123', + createdAt: '2023-08-02T09:07:41.680Z', + lastUsed: '2023-08-02T09:07:41.680Z', + valid: true, + } + ], + loading: false + }) + render() + + fireEvent.click(screen.getByTestId('delete-key-btn')) + await waitForEuiPopoverVisible() + + fireEvent.click(screen.getByTestId('delete-key-confirm-btn')) + + expect(store.getActions()).toEqual([getCapiKeys(), removeAllCapiKeys()]) + }) + + it('should call proper telemetry events', async () => { + apiService.delete = jest.fn().mockResolvedValueOnce({ status: 200 }) + const sendEventTelemetryMock = jest.fn() + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock); + + (oauthCapiKeysSelector as jest.Mock).mockReturnValue({ + data: [ + { + id: '1', + name: 'RedisInsight-f4868252-a128-4a02-af75-bd3c99898267-2020-11-01T-123', + createdAt: '2023-08-02T09:07:41.680Z', + lastUsed: '2023-08-02T09:07:41.680Z', + valid: true, + } + ], + loading: false + }) + render() + + fireEvent.click(screen.getByTestId('delete-key-btn')) + await waitForEuiPopoverVisible() + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEYS_REMOVE_CLICKED, + }) + + sendEventTelemetry.mockRestore() + + await act(() => { + fireEvent.click(screen.getByTestId('delete-key-confirm-btn')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEYS_REMOVED, + }) + + sendEventTelemetry.mockRestore() + }) +}) diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx b/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx new file mode 100644 index 0000000000..9e34b4a5d2 --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react' + +import { + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, + EuiButton, + EuiPopover, +} from '@elastic/eui' + +import { useDispatch, useSelector } from 'react-redux' +import { getCapiKeysAction, oauthCapiKeysSelector, removeAllCapiKeysAction } from 'uiSrc/slices/oauth/cloud' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import UserApiKeysTable from './components/user-api-keys-table' + +import styles from './styles.module.scss' + +const CloudSettings = () => { + const { loading, data } = useSelector(oauthCapiKeysSelector) + const [isDeleteOpen, setIsDeleteOpen] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + dispatch(getCapiKeysAction()) + }, []) + + const handleClickDelete = () => { + setIsDeleteOpen(true) + sendEventTelemetry({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEYS_REMOVE_CLICKED, + }) + } + + const handleDeleteAllKeys = () => { + setIsDeleteOpen(false) + dispatch(removeAllCapiKeysAction(() => { + sendEventTelemetry({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEYS_REMOVED, + }) + })) + } + + return ( +
+ + API user keys + + + + + + The list of API user keys that are stored locally in RedisInsight.
+ API user keys grant programmatic access to Redis Enterprise Cloud.
+ {'To delete API keys from Redis Enterprise Cloud, sign in to '} + + Redis Enterprise Cloud + + {' and delete them manually.'} +
+
+ + setIsDeleteOpen(false)} + panelPaddingSize="l" + panelClassName={styles.deletePopover} + button={( + + Remove all API keys + + )} + > +
+ +

+ All API user keys
will be removed from RedisInsight. +

+ {'To delete API keys from Redis Enterprise Cloud, '} + + sign in to Redis Enterprise Cloud + + {' and delete them manually.'} +
+ +
+ + Remove All keys + +
+
+
+
+
+ + +
+ ) +} + +export default CloudSettings diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.spec.tsx b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.spec.tsx new file mode 100644 index 0000000000..2b13a0485a --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.spec.tsx @@ -0,0 +1,131 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { cleanup, mockedStore, render, screen, fireEvent, waitForEuiPopoverVisible, act } from 'uiSrc/utils/test-utils' + +import { removeCapiKey } from 'uiSrc/slices/oauth/cloud' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { apiService } from 'uiSrc/services' +import UserApiKeysTable, { Props } from './UserApiKeysTable' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const mockedCapiKeys = [ + { + id: '1', + name: 'RedisInsight-f4868252-a128-4a02-af75-bd3c99898267-2020-11-01T-123', + createdAt: '2023-08-02T09:07:41.680Z', + lastUsed: '2023-08-02T09:07:41.680Z', + valid: true, + }, + { + id: '2', + name: 'RedisInsight-dawdaw68252-a128-4a02-af75-bd3c99898267-2020-11-01T-123', + createdAt: '2023-08-02T09:07:41.680Z', + lastUsed: '2023-08-02T09:07:41.680Z', + valid: false, + }, + { + id: '3', + name: 'RedisInsight-d4543wdaw68252-a128-4a02-af75-bd3c99898267-2020-11-01T-123', + createdAt: '2023-08-02T09:07:41.680Z', + lastUsed: '2023-08-02T09:07:41.680Z', + valid: false, + }, +] + +describe('UserApiKeysTable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render message when there are no keys', () => { + render() + + expect(screen.getByTestId('no-api-keys-message')).toBeInTheDocument() + }) + + it('should render row content properly', () => { + render() + + expect(screen.getByTestId(`row-${mockedCapiKeys[0].name}`)) + .toHaveTextContent('API Key NameRedisInsight-f4868252-a128-4a02-af75-bd3c99898267-2020-11-01T-123Created2 Aug 2023Last used2 Aug 2023') + }) + + it('should show delete popover and call proper action on delete', async () => { + render() + + fireEvent.click(screen.getByTestId(`remove-key-button-${mockedCapiKeys[0].name}-icon`)) + await waitForEuiPopoverVisible() + + fireEvent.click(screen.getByTestId(`remove-key-button-${mockedCapiKeys[0].name}`)) + + expect(store.getActions()).toEqual([removeCapiKey()]) + }) + + it('should call proper telemetry events', async () => { + const sendEventTelemetryMock = jest.fn() + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + apiService.delete = jest.fn().mockResolvedValue({ status: 200 }) + + const { container } = render() + + fireEvent.click(container.querySelector('[data-test-subj="tableHeaderSortButton"]') as HTMLElement) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEY_SORTED, + eventData: { + direction: 'asc', + field: 'name', + numberOfKeys: 3, + } + }) + + sendEventTelemetry.mockRestore() + + fireEvent.click(screen.getByTestId(`remove-key-button-${mockedCapiKeys[0].name}-icon`)) + await waitForEuiPopoverVisible() + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEY_REMOVE_CLICKED, + eventData: { + source: OAuthSocialSource.SettingsPage + } + }) + + sendEventTelemetry.mockRestore() + + await act(() => { + fireEvent.click(screen.getByTestId(`remove-key-button-${mockedCapiKeys[0].name}`)) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_API_KEY_REMOVED, + eventData: { + source: OAuthSocialSource.SettingsPage + } + }) + + sendEventTelemetry.mockRestore() + + fireEvent.click(screen.getByTestId(`copy-api-key-${mockedCapiKeys[0].name}`)) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEY_NAME_COPIED + }) + }) +}) diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx new file mode 100644 index 0000000000..351598d4ab --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx @@ -0,0 +1,270 @@ +import React, { useCallback, useState } from 'react' +import { + EuiBasicTableColumn, + EuiButtonIcon, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiText, + EuiTitle, + EuiToolTip, + PropertySort, + EuiSpacer, + EuiButton, + EuiButtonEmpty +} from '@elastic/eui' +import { format } from 'date-fns' +import cx from 'classnames' +import { useDispatch } from 'react-redux' +import { isNull } from 'lodash' +import { formatLongName, Maybe, Nullable } from 'uiSrc/utils' +import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { OAuthSocialHandlerDialog, OAuthSsoHandlerDialog } from 'uiSrc/components' +import { CloudCapiKey, OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { removeCapiKeyAction } from 'uiSrc/slices/oauth/cloud' + +import { ReactComponent as CloudStars } from 'uiSrc/assets/img/oauth/stars.svg' + +import styles from './styles.module.scss' + +export interface Props { + items: Nullable + loading: boolean +} + +const UserApiKeysTable = ({ items, loading }: Props) => { + const [sort, setSort] = useState>({ field: 'createdAt', direction: 'desc' }) + const [deleting, setDeleting] = useState('') + + const dispatch = useDispatch() + + const handleCopy = (value: string) => { + navigator?.clipboard?.writeText(value) + sendEventTelemetry({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEY_NAME_COPIED + }) + } + + const showPopover = useCallback((id = '') => { + setDeleting(id) + }, []) + + const handleSorting = ({ sort }: any) => { + setSort(sort) + sendEventTelemetry({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEY_SORTED, + eventData: { + ...sort, + numberOfKeys: items?.length || 0 + } + }) + } + + const handleClickDeleteApiKey = () => { + sendEventTelemetry({ + event: TelemetryEvent.SETTINGS_CLOUD_API_KEY_REMOVE_CLICKED, + eventData: { + source: OAuthSocialSource.SettingsPage + } + }) + } + + const handleDeleteApiKey = (id: string, name: string) => { + setDeleting('') + + dispatch(removeCapiKeyAction({ id, name }, () => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_API_KEY_REMOVED, + eventData: { + source: OAuthSocialSource.SettingsPage + } + }) + })) + } + + const columns: EuiBasicTableColumn[] = [ + { + name: 'API Key Name', + field: 'name', + sortable: true, + truncateText: true, + width: '100%', + render: (value: string, { valid }) => { + const tooltipContent = formatLongName(value) + + return ( +
+ {!valid && ( + + + + )} + + <>{value} + +
+ ) + } + }, + { + name: 'Created', + field: 'createdAt', + sortable: true, + truncateText: true, + width: '120x', + render: (value: number) => ( + + <>{format(new Date(value), 'd MMM yyyy')} + + ) + }, + { + name: 'Last used', + field: 'lastUsed', + sortable: true, + width: '120x', + render: (value: number) => ( + <> + {value && ( + + <>{format(new Date(value), 'd MMM yyyy')} + + )} + {!value && 'Never'} + + ) + }, + { + name: '', + field: 'actions', + align: 'right', + width: '80px', + render: (_value, { id, name }) => ( +
+ + handleCopy(name || '')} + style={{ marginRight: 4 }} + data-testid={`copy-api-key-${name}`} + /> + + + {'To delete this API key from Redis Enterprise Cloud, '} + + sign in to Redis Enterprise Cloud + + {' and delete it manually.'} + + )} + item={id} + suffix="" + deleting={deleting} + closePopover={() => setDeleting('')} + updateLoading={loading} + showPopover={showPopover} + testid={`remove-key-button-${name}`} + handleDeleteItem={() => handleDeleteApiKey(id, name)} + handleButtonClick={handleClickDeleteApiKey} + /> +
+ ) + }, + ] + + if (isNull(items)) return null + + if (!items?.length) { + return ( + <> +
+ + <> + + The ultimate Redis starting point + + + + + Cloud API keys will be created and stored when you connect to Redis Enterprise Cloud to create + a free Cloud database or autodiscover your Cloud database. + + +
+ + {(socialCloudHandlerClick) => ( + socialCloudHandlerClick(e, OAuthSocialSource.SettingsPage)} + data-testid="autodiscover-btn" + > + Autodiscover + + )} + + + {(ssoCloudHandlerClick) => ( + ssoCloudHandlerClick(e, OAuthSocialSource.SettingsPage)} + data-testid="create-cloud-db-btn" + > + Create Redis Cloud database + + )} + +
+
+ + + ) + } + + return ( + ({ + 'data-testid': `row-${row.name}`, + })} + data-testid="api-keys-table" + /> + ) +} + +export default UserApiKeysTable diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/index.ts b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/index.ts new file mode 100644 index 0000000000..1c577182a4 --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/index.ts @@ -0,0 +1,3 @@ +import UserApiKeysTable from './UserApiKeysTable' + +export default UserApiKeysTable diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/styles.module.scss b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/styles.module.scss new file mode 100644 index 0000000000..e0343dc808 --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/styles.module.scss @@ -0,0 +1,66 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.table { + @include euiScrollBar; + max-height: 240px; + overflow-y: auto; + + :global { + .euiTableCellContent { + padding: 10px 12px !important; + } + } + + &:global { + &.inMemoryTableDefault.noBorders .euiTableHeaderCell { + background-color: var(--euiColorEmptyShade); + border-bottom: 1px solid var(--euiColorLightShade) !important; + } + } +} + +.invalidIcon { + width: 14px !important; + height: 14px !important; + + margin-right: 4px; + margin-bottom: 1px; +} + +.nameField { + display: flex; + align-items: center; + + width: 100%; +} + +.noKeysMessage { + padding: 18px 30px; + + border: 1px solid var(--separatorColorLight); + border-radius: 4px; +} + +.starsIcon { + width: 24px !important; + height: 24px !important; + margin-right: 8px; +} + +.actions { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.autodiscoverBtn { + font-size: 13px !important; + margin-right: 8px; +} + + +.copyBtnAnchor { + display: inline !important; +} diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/index.ts b/redisinsight/ui/src/pages/settings/components/cloud-settings/index.ts new file mode 100644 index 0000000000..6b1ab26768 --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/index.ts @@ -0,0 +1,3 @@ +import CloudSettings from './CloudSettings' + +export default CloudSettings diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/styles.module.scss b/redisinsight/ui/src/pages/settings/components/cloud-settings/styles.module.scss new file mode 100644 index 0000000000..f64bd705fc --- /dev/null +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/styles.module.scss @@ -0,0 +1,13 @@ +.title { + font-size:16px !important; +} + +.deletePopover { + max-width: 420px !important; +} + +.popoverFooter { + display: flex; + justify-content: flex-end; +} + diff --git a/redisinsight/ui/src/pages/settings/components/index.ts b/redisinsight/ui/src/pages/settings/components/index.ts index 0413500097..fec0b45261 100644 --- a/redisinsight/ui/src/pages/settings/components/index.ts +++ b/redisinsight/ui/src/pages/settings/components/index.ts @@ -1,7 +1,9 @@ import AdvancedSettings from './advanced-settings' import WorkbenchSettings from './workbench-settings' +import CloudSettings from './cloud-settings' export { AdvancedSettings, - WorkbenchSettings + WorkbenchSettings, + CloudSettings, } diff --git a/redisinsight/ui/src/slices/app/notifications.ts b/redisinsight/ui/src/slices/app/notifications.ts index 51c0ebb31f..658b22ca06 100644 --- a/redisinsight/ui/src/slices/app/notifications.ts +++ b/redisinsight/ui/src/slices/app/notifications.ts @@ -5,7 +5,7 @@ import { ApiEndpoints } from 'uiSrc/constants' import { apiService } from 'uiSrc/services' import { getApiErrorMessage, getApiErrorName, isStatusSuccessful, Maybe, Nullable } from 'uiSrc/utils' import { NotificationsDto, NotificationDto } from 'apiSrc/modules/notification/dto' -import { InfiniteMessage, StateAppNotifications } from '../interfaces' +import { IError, InfiniteMessage, StateAppNotifications } from '../interfaces' import { AppDispatch, RootState } from '../store' @@ -37,6 +37,7 @@ const notificationsSlice = createSlice({ const title = payload?.response?.data?.title const errorName = getApiErrorName(payload) const message = getApiErrorMessage(payload) + const additionalInfo = payload?.response?.data?.additionalInfo const errorExistedId = state.errors.findIndex( (err) => err.message === message ) @@ -47,14 +48,20 @@ const notificationsSlice = createSlice({ }) } - state.errors.push({ + const error: IError = { ...payload, title, instanceId, id: `${Date.now()}`, name: errorName, message, - }) + } + + if (additionalInfo) { + error.additionalInfo = additionalInfo + } + + state.errors.push(error) }, removeError: (state, { payload = '' }: { payload: string }) => { state.errors = state.errors.filter((error) => error.id !== payload) diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index e75c035d55..6a8142c3f3 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -5,16 +5,13 @@ import { DurationUnits, FeatureFlags, ICommands } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { GetServerInfoResponse } from 'apiSrc/modules/server/dto/server.dto' import { RedisString as RedisStringAPI } from 'apiSrc/common/constants/redis-string' -import { CloudUser } from 'apiSrc/modules/cloud/user/models' -import { CloudJobInfo } from 'apiSrc/modules/cloud/job/models' -import { CloudSubscriptionPlanResponse } from 'apiSrc/modules/cloud/subscription/dto' -import { Instance } from './instances' export interface CustomError { error: string message: string statusCode: number errorCode?: number + resourceId?: string } export interface EnhancedAxiosError extends AxiosError { @@ -24,6 +21,7 @@ export interface IError extends AxiosError { id: string instanceId?: string title?: string + additionalInfo?: Record } export interface IMessage { @@ -225,48 +223,6 @@ export interface StateAppNotifications { } } -export interface StateAppOAuth { - loading: boolean - error: string - message: string - source: Nullable - job: Nullable - user: { - error: string - loading: boolean - data: Nullable - freeDb: CloudUserFreeDbState - } - plan: { - isOpenDialog: boolean - data: CloudSubscriptionPlanResponse[] - loading: boolean - } - isOpenSignInDialog: boolean - isOpenSelectAccountDialog: boolean - showProgress: boolean -} - -export interface CloudUserFreeDbState { - loading: boolean - error: string - data: Nullable -} - -export enum OAuthSocialSource { - ListOfDatabases = 'list of databases', - WelcomeScreen = 'welcome screen', - BrowserContentMenu = 'browser content menu', - BrowserFiltering = 'browser filtering', - BrowserSearch = 'browser search', - RediSearch = 'workbench RediSearch', - RedisJSON = 'workbench RedisJSON', - RedisTimeSeries = 'workbench RedisTimeSeries', - RedisGraph = 'workbench RedisGraph', - RedisBloom = 'workbench RedisBloom', - Autodiscovery = 'autodiscovery', -} - export interface StateAppActionBar { status: ActionBarStatus text?: string diff --git a/redisinsight/ui/src/slices/interfaces/cloud.ts b/redisinsight/ui/src/slices/interfaces/cloud.ts new file mode 100644 index 0000000000..0451772643 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/cloud.ts @@ -0,0 +1,63 @@ +import { Nullable } from 'uiSrc/utils' +import { Instance } from 'uiSrc/slices/interfaces/instances' + +import { CloudJobInfo } from 'apiSrc/modules/cloud/job/models' +import { CloudUser } from 'apiSrc/modules/cloud/user/models' +import { CloudSubscriptionPlanResponse } from 'apiSrc/modules/cloud/subscription/dto' + +export interface StateAppOAuth { + loading: boolean + error: string + message: string + source: Nullable + job: Nullable + user: { + error: string + loading: boolean + data: Nullable + freeDb: CloudUserFreeDbState + } + plan: { + isOpenDialog: boolean + data: CloudSubscriptionPlanResponse[] + loading: boolean + } + isOpenSocialDialog: boolean + isOpenSignInDialog: boolean + isOpenSelectAccountDialog: boolean + showProgress: boolean + capiKeys: { + loading: boolean + data: Nullable + } +} + +export interface CloudCapiKey { + id: string + name: string + valid: boolean + createdAt: string + lastUsed?: string +} + +export interface CloudUserFreeDbState { + loading: boolean + error: string + data: Nullable +} + +export enum OAuthSocialSource { + ListOfDatabases = 'list of databases', + WelcomeScreen = 'welcome screen', + BrowserContentMenu = 'browser content menu', + BrowserFiltering = 'browser filtering', + BrowserSearch = 'browser search', + RediSearch = 'workbench RediSearch', + RedisJSON = 'workbench RedisJSON', + RedisTimeSeries = 'workbench RedisTimeSeries', + RedisGraph = 'workbench RedisGraph', + RedisBloom = 'workbench RedisBloom', + Autodiscovery = 'autodiscovery', + SettingsPage = 'settings', + ConfirmationMessage = 'confirmation message' +} diff --git a/redisinsight/ui/src/slices/interfaces/index.ts b/redisinsight/ui/src/slices/interfaces/index.ts index 18340801ab..9f6d300387 100644 --- a/redisinsight/ui/src/slices/interfaces/index.ts +++ b/redisinsight/ui/src/slices/interfaces/index.ts @@ -6,3 +6,4 @@ export * from './monitor' export * from './api' export * from './bulkActions' export * from './redisearch' +export * from './cloud' diff --git a/redisinsight/ui/src/slices/oauth/cloud.ts b/redisinsight/ui/src/slices/oauth/cloud.ts index 8b93bdbfb3..7f7ff61250 100644 --- a/redisinsight/ui/src/slices/oauth/cloud.ts +++ b/redisinsight/ui/src/slices/oauth/cloud.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AxiosError } from 'axios' +import { remove } from 'lodash' import { apiService, localStorageService } from 'uiSrc/services' import { ApiEndpoints, BrowserStorageItem, Pages } from 'uiSrc/constants' import { getApiErrorMessage, isStatusSuccessful, Nullable } from 'uiSrc/utils' @@ -9,13 +10,19 @@ import { INFINITE_MESSAGES, InfiniteMessagesIds } from 'uiSrc/components/notifications/components' +import successMessages from 'uiSrc/components/notifications/success-messages' import { CloudUser } from 'apiSrc/modules/cloud/user/models' import { CloudJobInfo } from 'apiSrc/modules/cloud/job/models' import { CloudSubscriptionPlanResponse } from 'apiSrc/modules/cloud/subscription/dto' import { AppDispatch, RootState } from '../store' -import { Instance, OAuthSocialSource, StateAppOAuth } from '../interfaces' -import { addErrorNotification, addInfiniteNotification, removeInfiniteNotification } from '../app/notifications' +import { CloudCapiKey, Instance, OAuthSocialSource, StateAppOAuth } from '../interfaces' +import { + addErrorNotification, + addInfiniteNotification, + addMessageNotification, + removeInfiniteNotification +} from '../app/notifications' import { checkConnectToInstanceAction, setConnectedInstanceId } from '../instances/instances' import { setAppContextInitialState } from '../app/context' @@ -29,6 +36,7 @@ export const initialState: StateAppOAuth = { status: '', }, source: null, + isOpenSocialDialog: false, isOpenSignInDialog: false, isOpenSelectAccountDialog: false, showProgress: true, @@ -46,6 +54,10 @@ export const initialState: StateAppOAuth = { loading: false, isOpenDialog: false, data: [], + }, + capiKeys: { + loading: false, + data: null } } @@ -90,6 +102,10 @@ const oauthCloudSlice = createSlice({ state.user.freeDb.loading = false state.user.freeDb.error = payload }, + setSocialDialogState: (state, { payload }: PayloadAction>) => { + state.source = payload + state.isOpenSocialDialog = !!payload + }, setSignInDialogState: (state, { payload }: PayloadAction>) => { state.source = payload state.isOpenSignInDialog = !!payload @@ -121,6 +137,38 @@ const oauthCloudSlice = createSlice({ showOAuthProgress: (state, { payload }: PayloadAction) => { state.showProgress = payload }, + getCapiKeys: (state) => { + state.capiKeys.loading = true + }, + getCapiKeysSuccess: (state, { payload }: PayloadAction) => { + state.capiKeys.loading = false + state.capiKeys.data = payload + }, + getCapiKeysFailure: (state) => { + state.capiKeys.loading = false + }, + removeCapiKey: (state) => { + state.capiKeys.loading = true + }, + removeCapiKeySuccess: (state, { payload }: PayloadAction) => { + state.capiKeys.loading = false + if (state.capiKeys.data) { + remove(state.capiKeys.data, (item) => item.id === payload) + } + }, + removeCapiKeyFailure: (state) => { + state.capiKeys.loading = false + }, + removeAllCapiKeys: (state) => { + state.capiKeys.loading = true + }, + removeAllCapiKeysSuccess: (state) => { + state.capiKeys.loading = false + state.capiKeys.data = [] + }, + removeAllCapiKeysFailure: (state) => { + state.capiKeys.loading = false + }, }, }) @@ -136,6 +184,7 @@ export const { addFreeDb, addFreeDbSuccess, addFreeDbFailure, + setSocialDialogState, setSignInDialogState, setOAuthCloudSource, setSelectAccountDialogState, @@ -145,6 +194,15 @@ export const { getPlansSuccess, getPlansFailure, showOAuthProgress, + getCapiKeys, + getCapiKeysSuccess, + getCapiKeysFailure, + removeCapiKey, + removeCapiKeySuccess, + removeCapiKeyFailure, + removeAllCapiKeys, + removeAllCapiKeysSuccess, + removeAllCapiKeysFailure, } = oauthCloudSlice.actions // A selector @@ -153,6 +211,7 @@ export const oauthCloudJobSelector = (state: RootState) => state.oauth.cloud.job export const oauthCloudUserSelector = (state: RootState) => state.oauth.cloud.user export const oauthCloudUserDataSelector = (state: RootState) => state.oauth.cloud.user.data export const oauthCloudPlanSelector = (state: RootState) => state.oauth.cloud.plan +export const oauthCapiKeysSelector = (state: RootState) => state.oauth.cloud.capiKeys // The reducer export default oauthCloudSlice.reducer @@ -304,3 +363,78 @@ export function fetchPlans(onSuccessAction?: () => void, onFailAction?: () => vo } } } + +// Asynchronous thunk action +export function getCapiKeysAction(onSuccessAction?: () => void, onFailAction?: () => void) { + return async (dispatch: AppDispatch) => { + dispatch(getCapiKeys()) + + try { + const { data, status } = await apiService.get( + ApiEndpoints.CLOUD_CAPI_KEYS + ) + + if (isStatusSuccessful(status)) { + dispatch(getCapiKeysSuccess(data)) + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + dispatch(addErrorNotification(error)) + dispatch(getCapiKeysFailure()) + onFailAction?.() + } + } +} + +// Asynchronous thunk action +export function removeAllCapiKeysAction(onSuccessAction?: () => void, onFailAction?: () => void) { + return async (dispatch: AppDispatch) => { + dispatch(removeAllCapiKeys()) + + try { + const { status } = await apiService.delete( + ApiEndpoints.CLOUD_CAPI_KEYS + ) + + if (isStatusSuccessful(status)) { + dispatch(removeAllCapiKeysSuccess()) + dispatch(addMessageNotification(successMessages.REMOVED_ALL_CAPI_KEYS())) + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + dispatch(addErrorNotification(error)) + dispatch(removeAllCapiKeysFailure()) + onFailAction?.() + } + } +} + +// Asynchronous thunk action +export function removeCapiKeyAction( + { id, name }: { id: string, name: string }, + onSuccessAction?: () => void, + onFailAction?: () => void +) { + return async (dispatch: AppDispatch) => { + dispatch(removeCapiKey()) + + try { + const { status } = await apiService.delete( + `${ApiEndpoints.CLOUD_CAPI_KEYS}/${id}` + ) + + if (isStatusSuccessful(status)) { + dispatch(removeCapiKeySuccess(id)) + dispatch(addMessageNotification(successMessages.REMOVED_CAPI_KEY(name))) + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + dispatch(addErrorNotification(error)) + dispatch(removeCapiKeyFailure()) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/tests/oauth/cloud.spec.ts b/redisinsight/ui/src/slices/tests/oauth/cloud.spec.ts index a423a32474..bdf8999ef2 100644 --- a/redisinsight/ui/src/slices/tests/oauth/cloud.spec.ts +++ b/redisinsight/ui/src/slices/tests/oauth/cloud.spec.ts @@ -4,9 +4,15 @@ import { AxiosError } from 'axios' import { cleanup, clearStoreActions, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' import { apiService } from 'uiSrc/services' -import { addErrorNotification, addInfiniteNotification, removeInfiniteNotification } from 'uiSrc/slices/app/notifications' +import { + addErrorNotification, + addInfiniteNotification, + addMessageNotification, + removeInfiniteNotification +} from 'uiSrc/slices/app/notifications' import { INFINITE_MESSAGES, InfiniteMessagesIds } from 'uiSrc/components/notifications/components' import { CloudJobStatus, CloudJobName } from 'uiSrc/electron/constants' +import successMessages from 'uiSrc/components/notifications/success-messages' import reducer, { initialState, setSignInDialogState, @@ -32,6 +38,18 @@ import reducer, { getPlansFailure, setIsOpenSelectPlanDialog, showOAuthProgress, + getCapiKeys, + getCapiKeysSuccess, + getCapiKeysFailure, + removeCapiKey, + removeCapiKeySuccess, + removeCapiKeyFailure, + removeAllCapiKeysSuccess, + removeAllCapiKeysFailure, + removeAllCapiKeys, + getCapiKeysAction, + removeAllCapiKeysAction, + removeCapiKeyAction, } from '../../oauth/cloud' let store: typeof mockedStore @@ -459,6 +477,253 @@ describe('oauth cloud slice', () => { }) }) + describe('getCapiKeys', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: true, + } + } + + // Act + const nextState = reducer(initialState, getCapiKeys()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('getCapiKeysSuccess', () => { + it('should properly set the state', () => { + // Arrange + const data = [ + { + id: '1' + } + ] + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + data, + loading: false, + } + } + + // Act + const nextState = reducer(initialState, getCapiKeysSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('getCapiKeysFailure', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: false, + } + } + + // Act + const nextState = reducer(initialState, getCapiKeysFailure()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('removeCapiKey', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: true, + } + } + + // Act + const nextState = reducer(initialState, removeCapiKey()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('removeCapiKeySuccess', () => { + it('should properly set the state', () => { + // Arrange + const currentState = { + ...initialState, + capiKeys: { + data: [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ] + } + } + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: false, + data: [ + { id: '1' }, + { id: '3' }, + ] + } + } + + // Act + const nextState = reducer(currentState, removeCapiKeySuccess('2')) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('removeCapiKeyFailure', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: false, + } + } + + // Act + const nextState = reducer(initialState, removeCapiKeyFailure()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('removeAllCapiKeys', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: true, + } + } + + // Act + const nextState = reducer(initialState, removeAllCapiKeys()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('removeAllCapiKeysSuccess', () => { + it('should properly set the state', () => { + // Arrange + const currentState = { + ...initialState, + capiKeys: { + data: [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ] + } + } + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: false, + data: [] + } + } + + // Act + const nextState = reducer(currentState, removeAllCapiKeysSuccess()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + + describe('removeAllCapiKeysFailure', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + capiKeys: { + ...initialState.capiKeys, + loading: false, + } + } + + // Act + const nextState = reducer(initialState, removeAllCapiKeysFailure()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + oauth: { + cloud: nextState + } + }) + expect(oauthCloudSelector(rootState)).toEqual(state) + }) + }) + describe('thunks', () => { describe('fetchUserInfo', () => { it('call both fetchUserInfo and getUserInfoSuccess when fetch is successed', async () => { @@ -734,5 +999,135 @@ describe('oauth cloud slice', () => { expect(store.getActions()).toEqual(expectedActions) }) }) + + describe('getCapiKeysAction', () => { + it('should call proper actions on succeed', async () => { + const data = [ + { id: '1' }, + { id: '2' }, + ] + + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(getCapiKeysAction()) + + // Assert + const expectedActions = [ + getCapiKeys(), + getCapiKeysSuccess(data), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper actions on failed', async () => { + const errorMessage = 'Error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValueOnce(responsePayload) + + // Act + await store.dispatch(getCapiKeysAction()) + + // Assert + const expectedActions = [ + getCapiKeys(), + addErrorNotification(responsePayload as AxiosError), + getCapiKeysFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('removeAllCapiKeysAction', () => { + it('should call proper actions on succeed', async () => { + const responsePayload = { status: 200 } + + apiService.delete = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(removeAllCapiKeysAction()) + + // Assert + const expectedActions = [ + removeAllCapiKeys(), + removeAllCapiKeysSuccess(), + addMessageNotification(successMessages.REMOVED_ALL_CAPI_KEYS()) + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper actions on failed', async () => { + const errorMessage = 'Error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.delete = jest.fn().mockRejectedValueOnce(responsePayload) + + // Act + await store.dispatch(removeAllCapiKeysAction()) + + // Assert + const expectedActions = [ + removeAllCapiKeys(), + addErrorNotification(responsePayload as AxiosError), + removeAllCapiKeysFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('removeCapiKeyAction', () => { + it('should call proper actions on succeed', async () => { + const responsePayload = { status: 200 } + + apiService.delete = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(removeCapiKeyAction({ id: '1', name: 'Key' })) + + // Assert + const expectedActions = [ + removeCapiKey(), + removeCapiKeySuccess('1'), + addMessageNotification(successMessages.REMOVED_CAPI_KEY('Key')) + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper actions on failed', async () => { + const errorMessage = 'Error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.delete = jest.fn().mockRejectedValueOnce(responsePayload) + + // Act + await store.dispatch(removeCapiKeyAction({ id: '1', name: 'Key' })) + + // Assert + const expectedActions = [ + removeCapiKey(), + addErrorNotification(responsePayload as AxiosError), + removeCapiKeyFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) }) }) diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index c630f9a789..1d887f2054 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -96,6 +96,11 @@ export enum TelemetryEvent { SETTINGS_NOTIFICATION_MESSAGES_ENABLED = 'SETTINGS_NOTIFICATION_MESSAGES_ENABLED', SETTINGS_NOTIFICATION_MESSAGES_DISABLED = 'SETTINGS_NOTIFICATION_MESSAGES_DISABLED', SETTINGS_WORKBENCH_EDITOR_CLEAR_CHANGED = 'SETTINGS_WORKBENCH_EDITOR_CLEAR_CHANGED', + SETTINGS_CLOUD_API_KEY_NAME_COPIED = 'SETTINGS_CLOUD_API_KEY_NAME_COPIED', + SETTINGS_CLOUD_API_KEY_REMOVE_CLICKED = 'SETTINGS_CLOUD_API_KEY_REMOVE_CLICKED', + SETTINGS_CLOUD_API_KEYS_REMOVE_CLICKED = 'SETTINGS_CLOUD_API_KEYS_REMOVE_CLICKED', + SETTINGS_CLOUD_API_KEYS_REMOVED = 'SETTINGS_CLOUD_API_KEYS_REMOVED', + SETTINGS_CLOUD_API_KEY_SORTED = 'SETTINGS_CLOUD_API_KEY_SORTED', WORKBENCH_ENABLEMENT_AREA_GUIDE_OPENED = 'WORKBENCH_ENABLEMENT_AREA_GUIDE_OPENED', WORKBENCH_ENABLEMENT_AREA_COMMAND_CLICKED = 'WORKBENCH_ENABLEMENT_AREA_COMMAND_CLICKED', @@ -248,6 +253,8 @@ export enum TelemetryEvent { CLOUD_SIGN_IN_ACCOUNT_FORM_CLOSED = 'CLOUD_SIGN_IN_ACCOUNT_FORM_CLOSED', CLOUD_SIGN_IN_ACCOUNT_FAILED = 'CLOUD_SIGN_IN_ACCOUNT_FAILED', CLOUD_SIGN_IN_PROVIDER_FORM_CLOSED = 'CLOUD_SIGN_IN_PROVIDER_FORM_CLOSED', + CLOUD_IMPORT_DATABASES_CLICKED = 'CLOUD_IMPORT_DATABASES_CLICKED', + CLOUD_API_KEY_REMOVED = 'CLOUD_API_KEY_REMOVED', TRIGGERS_AND_FUNCTIONS_LIBRARIES_SORTED = 'TRIGGERS_AND_FUNCTIONS_LIBRARIES_SORTED', TRIGGERS_AND_FUNCTIONS_LIBRARY_LIST_REFRESH_CLICKED = 'TRIGGERS_AND_FUNCTIONS_LIBRARY_LIST_REFRESH_CLICKED', diff --git a/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx b/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx index 3e3f9cc797..e400b93f49 100644 --- a/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx +++ b/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx @@ -1,5 +1,5 @@ import { AxiosError } from 'axios' -import { isString, set } from 'lodash' +import { isEmpty, isString, set } from 'lodash' import React from 'react' import { EuiSpacer } from '@elastic/eui' import { CustomErrorCodes } from 'uiSrc/constants' @@ -20,6 +20,7 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M let title: string = 'Error' let message: React.ReactElement | string = '' + const additionalInfo: Record = {} switch (err?.errorCode) { case CustomErrorCodes.CloudOauthGithubEmailPermission: @@ -109,6 +110,7 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M ) break + case CustomErrorCodes.CloudCapiUnauthorized: case CustomErrorCodes.CloudApiUnauthorized: title = 'Unauthorized' message = ( @@ -122,6 +124,21 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M ) break + case CustomErrorCodes.CloudCapiKeyUnauthorized: + title = 'Invalid API key' + message = ( + <> + Your Redis Enterprise Cloud authorization failed. + + Remove the invalid API key from RedisInsight and try again. + + Open the Settings page to manage Redis Enterprise Cloud API keys. + + ) + additionalInfo.resourceId = err.resourceId + additionalInfo.errorCode = err.errorCode + break + case CustomErrorCodes.CloudDatabaseAlreadyExistsFree: title = 'Database already exists' message = ( @@ -135,9 +152,15 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M default: title = 'Error' - message = err?.message || error?.message || DEFAULT_ERROR_MESSAGE + message = err?.message || DEFAULT_ERROR_MESSAGE break } - return set(error, 'response.data', { title, message }) as AxiosError + const parsedError: any = { title, message } + + if (!isEmpty(additionalInfo)) { + parsedError.additionalInfo = additionalInfo + } + + return set(error, 'response.data', parsedError) as AxiosError } diff --git a/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx b/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx index 7b66330453..f0a53e790d 100644 --- a/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx +++ b/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx @@ -84,6 +84,23 @@ const parseCloudOAuthErrorTests = [ ) })], + [{ errorCode: 11_022 }, + set(cloneDeep(responseData), 'response.data', { + title: 'Invalid API key', + message: ( + <> + Your Redis Enterprise Cloud authorization failed. + + Remove the invalid API key from RedisInsight and try again. + + Open the Settings page to manage Redis Enterprise Cloud API keys. + + ), + additionalInfo: { + errorCode: 11022, + resourceId: undefined + } + })], ] describe('parseCloudOAuthError', () => {