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', () => {