From 83f572b0e30e87c632e749552f57c64e51091128 Mon Sep 17 00:00:00 2001 From: Egor Zalenski Date: Tue, 28 Jan 2025 18:30:08 +0100 Subject: [PATCH 1/3] #RI-6509 - Create a free db vscode --- .env | 4 +- .github/workflows/pipeline-build-linux.yml | 23 +- .github/workflows/pipeline-build-macos.yml | 23 +- .github/workflows/pipeline-build-windows.yml | 23 +- l10n/bundle.l10n.json | 51 +++- package.json | 12 +- src/WebViewProvider.ts | 3 +- src/Webview.ts | 10 +- src/constants.ts | 4 + src/extension.ts | 37 +-- src/lib/auth/auth.factory.ts | 5 + src/lib/auth/auth.handler.ts | 60 +++++ src/lib/auth/auth.interface.ts | 10 + src/lib/auth/models/cloud-auth-request.ts | 20 ++ src/lib/auth/models/cloud-auth-response.ts | 10 + src/lib/auth/models/session.ts | 12 + src/lib/auth/service.auth.strategy.ts | 67 ++++++ src/lib/update/checkVersionUpdate.ts | 2 +- src/logger.ts | 15 +- src/server/bootstrapBackend.ts | 23 +- src/server/bootstrapBackendE2E.ts | 6 +- src/{utils.ts => utils/handleMessage.ts} | 40 ++-- src/utils/handleUri.ts | 20 ++ src/utils/index.ts | 4 + src/utils/utils.ts | 19 ++ src/utils/wrapErrorSensitiveData.ts | 18 ++ src/webviews/src/actions/index.ts | 1 + src/webviews/src/actions/oauthCallback.ts | 88 +++++++ src/webviews/src/assets/icons/analysis.svg | 3 + src/webviews/src/assets/icons/bulk-upload.svg | 3 + .../src/assets/icons/bulk_actions.svg | 5 + src/webviews/src/assets/icons/champagne.svg | 59 +++++ src/webviews/src/assets/icons/check.svg | 3 + src/webviews/src/assets/icons/cheer.svg | 10 + src/webviews/src/assets/icons/connection.svg | 6 + src/webviews/src/assets/icons/copilot.svg | 5 + .../src/assets/icons/data-upload-bulk.svg | 3 + src/webviews/src/assets/icons/dislike.svg | 3 + .../src/assets/icons/formatter_dark.svg | 4 + .../src/assets/icons/formatter_light.svg | 4 + .../src/assets/icons/github-white.svg | 3 + src/webviews/src/assets/icons/group_mode.svg | 6 + src/webviews/src/assets/icons/help_illus.svg | 1 + src/webviews/src/assets/icons/like.svg | 3 + .../assets/icons/mobile_module_not_loaded.svg | 37 +++ .../src/assets/icons/module_not_loaded.svg | 37 +++ src/webviews/src/assets/icons/petard.svg | 9 + src/webviews/src/assets/icons/raw_mode.svg | 4 + .../src/assets/icons/recommendations_dark.svg | 24 ++ .../assets/icons/recommendations_light.svg | 24 ++ .../src/assets/icons/redis_db_blue.svg | 29 +++ src/webviews/src/assets/icons/rocket.svg | 37 +++ src/webviews/src/assets/icons/send.svg | 4 + src/webviews/src/assets/icons/silent_mode.svg | 10 + src/webviews/src/assets/icons/snooze.svg | 10 + src/webviews/src/assets/icons/star.svg | 3 + src/webviews/src/assets/icons/stars.svg | 7 + src/webviews/src/assets/icons/treeview.svg | 6 + src/webviews/src/assets/icons/user.svg | 7 + .../src/assets/icons/user_in_circle.svg | 10 + src/webviews/src/assets/icons/vector.svg | 3 + src/webviews/src/assets/icons/version.svg | 4 + src/webviews/src/assets/icons/warning.svg | 5 + src/webviews/src/assets/icons/welcome.svg | 64 +++++ .../src/assets/oauth/aws_provider.svg | 4 + .../src/assets/oauth/azure_provider.svg | 3 + src/webviews/src/assets/oauth/cloud.svg | 5 + .../src/assets/oauth/cloud_centered.svg | 5 + src/webviews/src/assets/oauth/cloud_color.svg | 8 + src/webviews/src/assets/oauth/cloud_link.svg | 4 + src/webviews/src/assets/oauth/confetti.svg | 11 + src/webviews/src/assets/oauth/developer.svg | 31 +++ src/webviews/src/assets/oauth/github.svg | 3 + .../src/assets/oauth/github_small.svg | 3 + src/webviews/src/assets/oauth/google.svg | 6 + .../src/assets/oauth/google_provider.svg | 8 + .../src/assets/oauth/google_small.svg | 3 + src/webviews/src/assets/oauth/hand.svg | 5 + src/webviews/src/assets/oauth/redisearch.svg | 5 + src/webviews/src/assets/oauth/rejson.svg | 5 + src/webviews/src/assets/oauth/rocket.svg | 10 + src/webviews/src/assets/oauth/sso.svg | 5 + src/webviews/src/assets/oauth/stars.svg | 5 + src/webviews/src/components/index.ts | 2 + .../global-toasts/GlobalToasts.tsx | 11 + .../notifications/global-toasts/index.ts | 1 + .../global-toasts/styles.module.scss | 14 ++ .../InfiniteMessages.spec.tsx | 100 ++++++++ .../infinite-messages/InfiniteMessages.tsx | 188 +++++++++++++++ .../notifications/infinite-messages/index.ts | 1 + src/webviews/src/constants/cloud/oauth.ts | 15 ++ src/webviews/src/constants/cloud/source.ts | 80 +++++++ src/webviews/src/constants/core/apiErrors.ts | 7 + .../src/constants/core/customErrorCodes.ts | 61 +++++ .../src/constants/environment/environment.ts | 13 ++ src/webviews/src/constants/external/links.ts | 21 ++ src/webviews/src/constants/index.ts | 4 + .../src/constants/sockets/socketErrors.ts | 3 + .../src/constants/sockets/socketEvents.ts | 13 ++ src/webviews/src/constants/vscode/vscode.ts | 4 + src/webviews/src/index.tsx | 9 +- src/webviews/src/interfaces/core/app.ts | 13 ++ src/webviews/src/interfaces/vscode/api.ts | 44 +++- src/webviews/src/mocks/data/oauth.ts | 21 ++ .../CommonAppSubscription.spec.tsx | 31 +++ .../CommonAppSubscription.tsx | 65 ++++++ .../modules/common-app-subscription/index.ts | 1 + .../modules/database-panel/DatabasePanel.tsx | 5 - src/webviews/src/modules/index.ts | 1 + src/webviews/src/modules/oauth/index.ts | 9 + src/webviews/src/modules/oauth/interfaces.ts | 42 ++++ .../OAuthCreateFreeDb.spec.tsx | 38 +++ .../OAuthCreateFreeDb.tsx | 73 ++++++ .../oauth/oauth-create-free-db/index.ts | 3 + .../oauth-create-free-db/styles.module.scss | 64 +++++ .../oauth/oauth-jobs/OAuthJobs.spec.tsx | 220 ++++++++++++++++++ .../modules/oauth/oauth-jobs/OAuthJobs.tsx | 151 ++++++++++++ .../src/modules/oauth/oauth-jobs/index.ts | 3 + .../oauth-sso-dialog/OAuthSsoDialog.spec.tsx | 54 +++++ .../oauth/oauth-sso-dialog/OAuthSsoDialog.tsx | 68 ++++++ .../modules/oauth/oauth-sso-dialog/index.ts | 3 + .../oauth/oauth-sso-dialog/styles.module.scss | 17 ++ .../src/modules/oauth/oauth-sso/index.ts | 5 + .../oauth-create-db/OAuthCreateDb.spec.tsx | 147 ++++++++++++ .../oauth-create-db/OAuthCreateDb.tsx | 124 ++++++++++ .../oauth/oauth-sso/oauth-create-db/index.ts | 3 + .../oauth-create-db/styles.module.scss | 23 ++ .../src/modules/oauth/shared/index.ts | 11 + .../oauth-advantages/OAuthAdvantages.spec.tsx | 10 + .../oauth-advantages/OAuthAdvantages.tsx | 26 +++ .../shared/oauth-advantages/constants.ts | 16 ++ .../oauth/shared/oauth-advantages/index.ts | 3 + .../oauth-advantages/styles.module.scss | 27 +++ .../oauth-agreement/OAuthAgreement.spec.tsx | 41 ++++ .../shared/oauth-agreement/OAuthAgreement.tsx | 93 ++++++++ .../oauth/shared/oauth-agreement/index.ts | 3 + .../shared/oauth-agreement/styles.module.scss | 24 ++ .../shared/oauth-form/OAuthForm.spec.tsx | 111 +++++++++ .../oauth/shared/oauth-form/OAuthForm.tsx | 94 ++++++++ .../oauth-sso-form/OAuthSsoForm.tsx | 112 +++++++++ .../components/oauth-sso-form/index.ts | 3 + .../oauth-sso-form/styles.module.scss | 11 + .../modules/oauth/shared/oauth-form/index.ts | 1 + .../OAuthRecommendedSettings.spec.tsx | 24 ++ .../OAuthRecommendedSettings.tsx | 46 ++++ .../oauth-recommended-settings/index.ts | 3 + .../styles.module.scss | 7 + .../OAuthSocialButtons.spec.tsx | 46 ++++ .../OAuthSocialButtons.tsx | 79 +++++++ .../shared/oauth-social-buttons/index.ts | 3 + .../oauth-social-buttons/styles.module.scss | 34 +++ .../pages/AddDatabasePage/AddDatabasePage.tsx | 16 +- .../EditDatabasePage/EditDatabasePage.tsx | 6 + src/webviews/src/pages/MainPage/MainPage.tsx | 2 + .../src/pages/SidebarPage/SidebarPage.tsx | 3 + .../use-app-info-store/useAppInfoStore.ts | 10 + .../src/store/hooks/use-oauth/interface.ts | 98 ++++++++ .../hooks/use-oauth/useOAuthStore.spec.ts | 72 ++++++ .../store/hooks/use-oauth/useOAuthStore.ts | 145 ++++++++++++ src/webviews/src/store/index.ts | 1 + .../src/styles/components/_popup.scss | 2 +- src/webviews/src/types/index.d.ts | 2 + src/webviews/src/ui/spinner/Spinner.tsx | 5 +- src/webviews/src/utils/core/apiResponses.ts | 18 +- src/webviews/src/utils/core/errors.tsx | 185 ++++++++++++++- src/webviews/src/utils/core/index.ts | 10 +- src/webviews/src/utils/index.ts | 2 + src/webviews/src/utils/notifications/index.ts | 1 + .../src/utils/notifications/toasts.tsx | 39 ++++ src/webviews/src/utils/oauth/cloudSsoUtm.tsx | 43 ++++ src/webviews/src/utils/telemetry/events.ts | 9 + src/webviews/test/handlers/index.ts | 2 + src/webviews/test/handlers/oauth/index.ts | 8 + .../test/handlers/oauth/oauthHandlers.ts | 17 ++ src/webviews/test/helpers/constants.ts | 3 + yarn.lock | 92 +++++++- 176 files changed, 4502 insertions(+), 86 deletions(-) create mode 100644 src/lib/auth/auth.factory.ts create mode 100644 src/lib/auth/auth.handler.ts create mode 100644 src/lib/auth/auth.interface.ts create mode 100644 src/lib/auth/models/cloud-auth-request.ts create mode 100644 src/lib/auth/models/cloud-auth-response.ts create mode 100644 src/lib/auth/models/session.ts create mode 100644 src/lib/auth/service.auth.strategy.ts rename src/{utils.ts => utils/handleMessage.ts} (75%) create mode 100644 src/utils/handleUri.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/utils.ts create mode 100644 src/utils/wrapErrorSensitiveData.ts create mode 100644 src/webviews/src/actions/oauthCallback.ts create mode 100644 src/webviews/src/assets/icons/analysis.svg create mode 100644 src/webviews/src/assets/icons/bulk-upload.svg create mode 100644 src/webviews/src/assets/icons/bulk_actions.svg create mode 100644 src/webviews/src/assets/icons/champagne.svg create mode 100644 src/webviews/src/assets/icons/check.svg create mode 100644 src/webviews/src/assets/icons/cheer.svg create mode 100644 src/webviews/src/assets/icons/connection.svg create mode 100644 src/webviews/src/assets/icons/copilot.svg create mode 100644 src/webviews/src/assets/icons/data-upload-bulk.svg create mode 100644 src/webviews/src/assets/icons/dislike.svg create mode 100644 src/webviews/src/assets/icons/formatter_dark.svg create mode 100644 src/webviews/src/assets/icons/formatter_light.svg create mode 100644 src/webviews/src/assets/icons/github-white.svg create mode 100644 src/webviews/src/assets/icons/group_mode.svg create mode 100644 src/webviews/src/assets/icons/help_illus.svg create mode 100644 src/webviews/src/assets/icons/like.svg create mode 100644 src/webviews/src/assets/icons/mobile_module_not_loaded.svg create mode 100644 src/webviews/src/assets/icons/module_not_loaded.svg create mode 100644 src/webviews/src/assets/icons/petard.svg create mode 100644 src/webviews/src/assets/icons/raw_mode.svg create mode 100644 src/webviews/src/assets/icons/recommendations_dark.svg create mode 100644 src/webviews/src/assets/icons/recommendations_light.svg create mode 100644 src/webviews/src/assets/icons/redis_db_blue.svg create mode 100644 src/webviews/src/assets/icons/rocket.svg create mode 100644 src/webviews/src/assets/icons/send.svg create mode 100644 src/webviews/src/assets/icons/silent_mode.svg create mode 100644 src/webviews/src/assets/icons/snooze.svg create mode 100644 src/webviews/src/assets/icons/star.svg create mode 100644 src/webviews/src/assets/icons/stars.svg create mode 100644 src/webviews/src/assets/icons/treeview.svg create mode 100644 src/webviews/src/assets/icons/user.svg create mode 100644 src/webviews/src/assets/icons/user_in_circle.svg create mode 100644 src/webviews/src/assets/icons/vector.svg create mode 100644 src/webviews/src/assets/icons/version.svg create mode 100644 src/webviews/src/assets/icons/warning.svg create mode 100644 src/webviews/src/assets/icons/welcome.svg create mode 100644 src/webviews/src/assets/oauth/aws_provider.svg create mode 100644 src/webviews/src/assets/oauth/azure_provider.svg create mode 100644 src/webviews/src/assets/oauth/cloud.svg create mode 100644 src/webviews/src/assets/oauth/cloud_centered.svg create mode 100644 src/webviews/src/assets/oauth/cloud_color.svg create mode 100644 src/webviews/src/assets/oauth/cloud_link.svg create mode 100644 src/webviews/src/assets/oauth/confetti.svg create mode 100644 src/webviews/src/assets/oauth/developer.svg create mode 100644 src/webviews/src/assets/oauth/github.svg create mode 100644 src/webviews/src/assets/oauth/github_small.svg create mode 100644 src/webviews/src/assets/oauth/google.svg create mode 100644 src/webviews/src/assets/oauth/google_provider.svg create mode 100644 src/webviews/src/assets/oauth/google_small.svg create mode 100644 src/webviews/src/assets/oauth/hand.svg create mode 100644 src/webviews/src/assets/oauth/redisearch.svg create mode 100644 src/webviews/src/assets/oauth/rejson.svg create mode 100644 src/webviews/src/assets/oauth/rocket.svg create mode 100644 src/webviews/src/assets/oauth/sso.svg create mode 100644 src/webviews/src/assets/oauth/stars.svg create mode 100644 src/webviews/src/components/notifications/global-toasts/GlobalToasts.tsx create mode 100644 src/webviews/src/components/notifications/global-toasts/index.ts create mode 100644 src/webviews/src/components/notifications/global-toasts/styles.module.scss create mode 100644 src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.spec.tsx create mode 100644 src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.tsx create mode 100644 src/webviews/src/components/notifications/infinite-messages/index.ts create mode 100644 src/webviews/src/constants/cloud/oauth.ts create mode 100644 src/webviews/src/constants/core/customErrorCodes.ts create mode 100644 src/webviews/src/constants/sockets/socketErrors.ts create mode 100644 src/webviews/src/constants/sockets/socketEvents.ts create mode 100644 src/webviews/src/mocks/data/oauth.ts create mode 100644 src/webviews/src/modules/common-app-subscription/CommonAppSubscription.spec.tsx create mode 100644 src/webviews/src/modules/common-app-subscription/CommonAppSubscription.tsx create mode 100644 src/webviews/src/modules/common-app-subscription/index.ts create mode 100644 src/webviews/src/modules/oauth/index.ts create mode 100644 src/webviews/src/modules/oauth/interfaces.ts create mode 100644 src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.spec.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-create-free-db/index.ts create mode 100644 src/webviews/src/modules/oauth/oauth-create-free-db/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.spec.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-jobs/index.ts create mode 100644 src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.spec.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-sso-dialog/index.ts create mode 100644 src/webviews/src/modules/oauth/oauth-sso-dialog/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/oauth-sso/index.ts create mode 100644 src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.spec.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.tsx create mode 100644 src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/index.ts create mode 100644 src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/shared/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.spec.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-advantages/constants.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-advantages/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-advantages/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.spec.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-agreement/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-agreement/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.spec.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/OAuthSsoForm.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/shared/oauth-form/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.spec.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-recommended-settings/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-recommended-settings/styles.module.scss create mode 100644 src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.spec.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.tsx create mode 100644 src/webviews/src/modules/oauth/shared/oauth-social-buttons/index.ts create mode 100644 src/webviews/src/modules/oauth/shared/oauth-social-buttons/styles.module.scss create mode 100644 src/webviews/src/store/hooks/use-oauth/interface.ts create mode 100644 src/webviews/src/store/hooks/use-oauth/useOAuthStore.spec.ts create mode 100644 src/webviews/src/store/hooks/use-oauth/useOAuthStore.ts create mode 100644 src/webviews/src/utils/notifications/index.ts create mode 100644 src/webviews/src/utils/notifications/toasts.tsx create mode 100644 src/webviews/src/utils/oauth/cloudSsoUtm.tsx create mode 100644 src/webviews/test/handlers/oauth/index.ts create mode 100644 src/webviews/test/handlers/oauth/oauthHandlers.ts diff --git a/.env b/.env index 9dae0d2c..251378de 100644 --- a/.env +++ b/.env @@ -8,7 +8,7 @@ RI_APP_PORT=5541 RI_APP_VERSION='1.2.0' RI_APP_PREFIX='api' RI_APP_FOLDER_NAME='.redis-for-vscode' -RI_CDN_PATH='https://s3.amazonaws.com/redisinsight.download/public/releases/2.64.0/web-mini' +RI_CDN_PATH='https://s3.amazonaws.com/redisinsight.test/public/pre-release/2.66.0/web-mini' RI_WITHOUT_BACKEND=false # RI_WITHOUT_BACKEND=true RI_STDOUT_LOGGER=false @@ -18,4 +18,6 @@ RI_BUILD_TYPE='VS_CODE' RI_ANALYTICS_START_EVENTS=true RI_AGREEMENTS_PATH='../../webviews/resources/agreements-spec.json' RI_ENCRYPTION_KEYTAR_SERVICE="redis-for-vscode" +RI_SOCKETS_CORS=true # RI_SEGMENT_WRITE_KEY='SEGMENT_WRITE_KEY' + diff --git a/.github/workflows/pipeline-build-linux.yml b/.github/workflows/pipeline-build-linux.yml index f9519361..1ec3e0fc 100644 --- a/.github/workflows/pipeline-build-linux.yml +++ b/.github/workflows/pipeline-build-linux.yml @@ -34,8 +34,19 @@ jobs: - name: Download backend uses: ./.github/actions/download-backend - - name: Set RI_SEGMENT_WRITE_KEY to .env file - run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} + - name: Configure Environment Variables + run: | + { + echo "RI_SEGMENT_WRITE_KEY=${{ env.RI_SEGMENT_WRITE_KEY }}" + echo "RI_CLOUD_IDP_AUTHORIZE_URL=${{ env.RI_CLOUD_IDP_AUTHORIZE_URL }}" + echo "RI_CLOUD_IDP_TOKEN_URL=${{ env.RI_CLOUD_IDP_TOKEN_URL }}" + echo "RI_CLOUD_IDP_REVOKE_TOKEN_URL=${{ env.RI_CLOUD_IDP_REVOKE_TOKEN_URL }}" + echo "RI_CLOUD_IDP_REDIRECT_URI=${{ env.RI_CLOUD_IDP_REDIRECT_URI }}" + echo "RI_CLOUD_IDP_ISSUER=${{ env.RI_CLOUD_IDP_ISSUER }}" + echo "RI_CLOUD_IDP_CLIENT_ID=${{ env.RI_CLOUD_IDP_CLIENT_ID }}" + echo "RI_CLOUD_IDP_GOOGLE_ID=${{ env.RI_CLOUD_IDP_GOOGLE_ID }}" + echo "RI_CLOUD_IDP_GH_ID=${{ env.RI_CLOUD_IDP_GH_ID }}" + } >> "${{ env.envFile }}" - name: Build linux package (production) if: inputs.environment == 'production' @@ -59,3 +70,11 @@ jobs: envFile: '.env' packagePath: './release/redis-for-vscode-extension-linux-x64.vsix' RI_SEGMENT_WRITE_KEY: ${{ secrets.RI_SEGMENT_WRITE_KEY }} + RI_CLOUD_IDP_AUTHORIZE_URL: ${{ secrets.RI_CLOUD_IDP_AUTHORIZE_URL }} + RI_CLOUD_IDP_TOKEN_URL: ${{ secrets.RI_CLOUD_IDP_TOKEN_URL }} + RI_CLOUD_IDP_REVOKE_TOKEN_URL: ${{ secrets.RI_CLOUD_IDP_REVOKE_TOKEN_URL }} + RI_CLOUD_IDP_REDIRECT_URI: ${{ secrets.RI_CLOUD_IDP_REDIRECT_URI }} + RI_CLOUD_IDP_ISSUER: ${{ secrets.RI_CLOUD_IDP_ISSUER }} + RI_CLOUD_IDP_CLIENT_ID: ${{ secrets.RI_CLOUD_IDP_CLIENT_ID }} + RI_CLOUD_IDP_GOOGLE_ID: ${{ secrets.RI_CLOUD_IDP_GOOGLE_ID }} + RI_CLOUD_IDP_GH_ID: ${{ secrets.RI_CLOUD_IDP_GH_ID }} diff --git a/.github/workflows/pipeline-build-macos.yml b/.github/workflows/pipeline-build-macos.yml index c12a9b3e..ffee3147 100644 --- a/.github/workflows/pipeline-build-macos.yml +++ b/.github/workflows/pipeline-build-macos.yml @@ -27,8 +27,19 @@ jobs: - name: Install all libs and dependencies uses: ./.github/actions/install-all-build-libs - - name: Set RI_SEGMENT_WRITE_KEY to .env file - run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} + - name: Configure Environment Variables + run: | + { + echo "RI_SEGMENT_WRITE_KEY=${{ env.RI_SEGMENT_WRITE_KEY }}" + echo "RI_CLOUD_IDP_AUTHORIZE_URL=${{ env.RI_CLOUD_IDP_AUTHORIZE_URL }}" + echo "RI_CLOUD_IDP_TOKEN_URL=${{ env.RI_CLOUD_IDP_TOKEN_URL }}" + echo "RI_CLOUD_IDP_REVOKE_TOKEN_URL=${{ env.RI_CLOUD_IDP_REVOKE_TOKEN_URL }}" + echo "RI_CLOUD_IDP_REDIRECT_URI=${{ env.RI_CLOUD_IDP_REDIRECT_URI }}" + echo "RI_CLOUD_IDP_ISSUER=${{ env.RI_CLOUD_IDP_ISSUER }}" + echo "RI_CLOUD_IDP_CLIENT_ID=${{ env.RI_CLOUD_IDP_CLIENT_ID }}" + echo "RI_CLOUD_IDP_GOOGLE_ID=${{ env.RI_CLOUD_IDP_GOOGLE_ID }}" + echo "RI_CLOUD_IDP_GH_ID=${{ env.RI_CLOUD_IDP_GH_ID }}" + } >> "${{ env.envFile }}" - name: Download backend x64 uses: ./.github/actions/download-backend @@ -76,3 +87,11 @@ jobs: envFile: '.env' packagePath: './release/redis-for-vscode-extension-mac' RI_SEGMENT_WRITE_KEY: ${{ secrets.RI_SEGMENT_WRITE_KEY }} + RI_CLOUD_IDP_AUTHORIZE_URL: ${{ secrets.RI_CLOUD_IDP_AUTHORIZE_URL }} + RI_CLOUD_IDP_TOKEN_URL: ${{ secrets.RI_CLOUD_IDP_TOKEN_URL }} + RI_CLOUD_IDP_REVOKE_TOKEN_URL: ${{ secrets.RI_CLOUD_IDP_REVOKE_TOKEN_URL }} + RI_CLOUD_IDP_REDIRECT_URI: ${{ secrets.RI_CLOUD_IDP_REDIRECT_URI }} + RI_CLOUD_IDP_ISSUER: ${{ secrets.RI_CLOUD_IDP_ISSUER }} + RI_CLOUD_IDP_CLIENT_ID: ${{ secrets.RI_CLOUD_IDP_CLIENT_ID }} + RI_CLOUD_IDP_GOOGLE_ID: ${{ secrets.RI_CLOUD_IDP_GOOGLE_ID }} + RI_CLOUD_IDP_GH_ID: ${{ secrets.RI_CLOUD_IDP_GH_ID }} diff --git a/.github/workflows/pipeline-build-windows.yml b/.github/workflows/pipeline-build-windows.yml index dc3600e1..0e4a7d4b 100644 --- a/.github/workflows/pipeline-build-windows.yml +++ b/.github/workflows/pipeline-build-windows.yml @@ -24,8 +24,19 @@ jobs: - name: Download backend uses: ./.github/actions/download-backend - - name: Set RI_SEGMENT_WRITE_KEY to .env file - run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} + - name: Configure Environment Variables + run: | + { + echo "RI_SEGMENT_WRITE_KEY=${{ env.RI_SEGMENT_WRITE_KEY }}" + echo "RI_CLOUD_IDP_AUTHORIZE_URL=${{ env.RI_CLOUD_IDP_AUTHORIZE_URL }}" + echo "RI_CLOUD_IDP_TOKEN_URL=${{ env.RI_CLOUD_IDP_TOKEN_URL }}" + echo "RI_CLOUD_IDP_REVOKE_TOKEN_URL=${{ env.RI_CLOUD_IDP_REVOKE_TOKEN_URL }}" + echo "RI_CLOUD_IDP_REDIRECT_URI=${{ env.RI_CLOUD_IDP_REDIRECT_URI }}" + echo "RI_CLOUD_IDP_ISSUER=${{ env.RI_CLOUD_IDP_ISSUER }}" + echo "RI_CLOUD_IDP_CLIENT_ID=${{ env.RI_CLOUD_IDP_CLIENT_ID }}" + echo "RI_CLOUD_IDP_GOOGLE_ID=${{ env.RI_CLOUD_IDP_GOOGLE_ID }}" + echo "RI_CLOUD_IDP_GH_ID=${{ env.RI_CLOUD_IDP_GH_ID }}" + } >> "${{ env.envFile }}" - name: Build windows package (production) if: inputs.environment == 'production' @@ -49,3 +60,11 @@ jobs: envFile: '.env' packagePath: './release/redis-for-vscode-extension-win-x64.vsix' RI_SEGMENT_WRITE_KEY: ${{ secrets.RI_SEGMENT_WRITE_KEY }} + RI_CLOUD_IDP_AUTHORIZE_URL: ${{ secrets.RI_CLOUD_IDP_AUTHORIZE_URL }} + RI_CLOUD_IDP_TOKEN_URL: ${{ secrets.RI_CLOUD_IDP_TOKEN_URL }} + RI_CLOUD_IDP_REVOKE_TOKEN_URL: ${{ secrets.RI_CLOUD_IDP_REVOKE_TOKEN_URL }} + RI_CLOUD_IDP_REDIRECT_URI: ${{ secrets.RI_CLOUD_IDP_REDIRECT_URI }} + RI_CLOUD_IDP_ISSUER: ${{ secrets.RI_CLOUD_IDP_ISSUER }} + RI_CLOUD_IDP_CLIENT_ID: ${{ secrets.RI_CLOUD_IDP_CLIENT_ID }} + RI_CLOUD_IDP_GOOGLE_ID: ${{ secrets.RI_CLOUD_IDP_GOOGLE_ID }} + RI_CLOUD_IDP_GH_ID: ${{ secrets.RI_CLOUD_IDP_GH_ID }} diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 321052a8..a3ba4ac5 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -16,8 +16,40 @@ "Create new database": "Create new database", "Recommended": "Recommended", "Page was not found": "Page was not found", + "Edit Redis database": "Edit Redis database", + "Add Redis database": "Add Redis database", "Settings": "Settings", "Delimiter": "Delimiter", + "Use a pre-selected provider and region": "Use a pre-selected provider and region", + "The database will be automatically created using a pre-selected provider and region.": "The database will be automatically created using a pre-selected provider and region.", + "You can change it by signing in to Redis Cloud.": "You can change it by signing in to Redis Cloud.", + "Invalid email": "Invalid email", + "Email must be in the format": "Email must be in the format", + "email@example.com without spaces": "email@example.com without spaces", + "Single Sign-On": "Single Sign-On", + "Email": "Email", + "Back": "Back", + "By signing up, you acknowledge that you agree:": "By signing up, you acknowledge that you agree:", + "to our ": "to our ", + "Cloud Terms of Service": "Cloud Terms of Service", + " and ": " and ", + "Privacy Policy": "Privacy Policy", + "that Redis for VS Code will generate Redis Cloud API account and user keys, and store them locally on your machine": "that Redis for VS Code will generate Redis Cloud API account and user keys, and store them locally on your machine", + "that usage data will be enabled to help us understand and improve how Redis for VS Code features are used": "that usage data will be enabled to help us understand and improve how Redis for VS Code features are used", + "Structured querying and full-text search": "Structured querying and full-text search", + "Native support for JSON": "Native support for JSON", + "Scalable and fully managed": "Scalable and fully managed", + "Free database to get started immediately": "Free database to get started immediately", + "Cloud": "Cloud", + "Get started with": "Get started with", + "Free Cloud database": "Free Cloud database", + "Get your": "Get your", + "The database will be created automatically and can be changed from Redis Cloud.": "The database will be created automatically and can be changed from Redis Cloud.", + "Create": "Create", + "Includes native support for JSON, Query and Search and more.": "Includes native support for JSON, Query and Search and more.", + "Get free Redis Cloud database": "Get free Redis Cloud database", + "Create free Redis Cloud database": "Create free Redis Cloud database", + "Try Redis Cloud database: your ultimate Redis starting point": "Try Redis Cloud database: your ultimate Redis starting point", "key(s)": "key(s)", "({0}{1} Scanned)": "({0}{1} Scanned)", "All Key Types": "All Key Types", @@ -93,8 +125,6 @@ "To optimize your experience, Redis for VS Code uses third-party tools.\n All data collected is anonymized and will not be used for any purpose without your consent.": "To optimize your experience, Redis for VS Code uses third-party tools.\n All data collected is anonymized and will not be used for any purpose without your consent.", "To use Redis for VS Code, please accept the terms and conditions: ": "To use Redis for VS Code, please accept the terms and conditions: ", "Server Side Public License": "Server Side Public License", - "Add Redis database": "Add Redis database", - "Edit Redis database": "Edit Redis database", "Members": "Members", "Add Key": "Add Key", "value": "value", @@ -253,6 +283,23 @@ "Upload": "Upload", "The entire database has been scanned.": "The entire database has been scanned.", "Scan more": "Scan more", + "Authenticating…": "Authenticating…", + "This may take several seconds, but it is totally worth it!": "This may take several seconds, but it is totally worth it!", + "Processing Cloud API keys…": "Processing Cloud API keys…", + "Processing Cloud subscriptions…": "Processing Cloud subscriptions…", + "Creating a free Cloud database…": "Creating a free Cloud database…", + "Importing a free Cloud database…": "Importing a free Cloud database…", + "This may take several minutes, but it is totally worth it!": "This may take several minutes, but it is totally worth it!", + "You can now use your Redis Stack database in Redis Cloud": "You can now use your Redis Stack database in Redis Cloud", + " with pre-loaded sample data": " with pre-loaded sample data", + "Congratulations!": "Congratulations!", + "Notice: ": "Notice: ", + "the database will be deleted after 15 days of inactivity.": "the database will be deleted after 15 days of inactivity.", + "You already have a free Redis Cloud subscription.": "You already have a free Redis Cloud subscription.", + "Do you want to import your existing database into Redis Insight?": "Do you want to import your existing database into Redis Insight?", + "Import": "Import", + "Your subscription does not have a free Redis Cloud database.": "Your subscription does not have a free Redis Cloud database.", + "Do you want to create a free database in your existing subscription?": "Do you want to create a free database in your existing subscription?", "Keys are the foundation of Redis.": "Keys are the foundation of Redis.", "Add key": "Add key", "No results found.": "No results found.", diff --git a/package.json b/package.json index 45c2e7a3..398799c3 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,12 @@ "title": "Add Redis database", "category": "Redis for VS Code", "icon": "$(add)" + }, + { + "command": "RedisForVSCode.showExtensionOutput", + "title": "Show extension output", + "category": "Redis for VS Code", + "icon": "$(add)" } ], "menus": { @@ -138,7 +144,7 @@ "scripts": { "vscode:prepublish": "yarn compile && cross-env NODE_ENV=production BUILD_EXIT=true yarn build", "compile": "tsc -p ./", - "postinstall": "patch-package", + "postinstall": "patch-package && yarn download:backend", "build": "cross-env NODE_ENV=production vite build", "download:backend": "tsc ./scripts/downloadBackend.ts && node ./scripts/downloadBackend.js", "dev": "vite dev", @@ -228,9 +234,11 @@ "postcss-nested": "^6.0.1", "postinstall-postinstall": "^2.1.0", "prettier": "^3.0.0", + "react-element-to-jsx-string": "^17.0.0", "react-intl": "^6.5.1", "react-refresh": "^0.14.0", "sass": "^1.69.5", + "socket.io-mock": "^1.3.2", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "tailwindcss": "^3.4.3", @@ -291,11 +299,13 @@ "react-router-dom": "^6.17.0", "react-select": "^5.8.3", "react-spinners": "^0.13.8", + "react-toastify": "^11.0.3", "react-virtualized": "^9.22.5", "react-virtualized-auto-sizer": "^1.0.20", "react-vtree": "^3.0.0-beta.3", "react-window": "^1.8.6", "reactjs-popup": "^2.0.6", + "socket.io-client": "^4.8.1", "ws": "^8.17.1", "zustand": "^4.5.4" } diff --git a/src/WebViewProvider.ts b/src/WebViewProvider.ts index e7271774..1861fb91 100644 --- a/src/WebViewProvider.ts +++ b/src/WebViewProvider.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode' -import { getNonce, handleMessage } from './utils' +import { getNonce } from './utils/utils' import { getUIStorage } from './lib' +import { handleMessage } from './utils/handleMessage' export class WebViewProvider implements vscode.WebviewViewProvider { _doc?: vscode.TextDocument diff --git a/src/Webview.ts b/src/Webview.ts index 0e0223ab..d17e5066 100644 --- a/src/Webview.ts +++ b/src/Webview.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode' -import { getNonce, handleMessage } from './utils' +import { getNonce } from './utils/utils' import { getUIStorage } from './lib' +import { EXTENSION_NAME } from './constants' +import { handleMessage } from './utils/handleMessage' type WebviewOptions = { context?: vscode.ExtensionContext @@ -50,8 +52,8 @@ abstract class Webview { } } - protected handleMessage(message: any): void { - this._opts?.handleMessage?.(message) + protected async handleMessage(message: any): Promise { + handleMessage(message) } protected _getContent(webview: vscode.Webview) { @@ -103,7 +105,7 @@ abstract class Webview { window.ri=${uiStorageStringify}; - Redis for VS Code Webview + ${EXTENSION_NAME} Webview
diff --git a/src/constants.ts b/src/constants.ts index d04a667a..a3b9a107 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,7 +10,11 @@ export enum ViewId { export const MAX_TITLE_KEY_LENGTH = 30 export const EXTENSION_ID = 'Redis.redis-for-vscode' +export const EXTENSION_NAME = 'Redis for VS Code' export const EXTERNAL_LINKS = { releaseNotes: 'https://github.com/RedisInsight/Redis-for-VS-Code/releases', } + +export const DEFAULT_USER_ID = '1' +export const DEFAULT_SESSION_ID = '1' diff --git a/src/extension.ts b/src/extension.ts index 6bd9af33..4df929aa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-mutable-exports */ import * as vscode from 'vscode' import * as dotenv from 'dotenv' import * as path from 'path' @@ -9,12 +10,15 @@ import { WebViewProvider } from './WebViewProvider' import { getTitleForKey, handleMessage } from './utils' import { ViewId } from './constants' import { logger } from './logger' +import { registerUriHandler } from './utils/handleUri' dotenv.config({ path: path.join(__dirname, '..', '.env') }) -let myStatusBarItem: vscode.StatusBarItem +export let sidebarProvider: WebViewProvider +export let panelProvider: WebViewProvider + export async function activate(context: vscode.ExtensionContext) { - logger.log('Extension activated') + logger.logCore('Extension activated') await initWorkspaceState(context) checkVersionUpdate() @@ -27,24 +31,13 @@ export async function activate(context: vscode.ExtensionContext) { } } } catch (error) { - logger.log(`startBackend error: ${error}`) + logger.logCore(`startBackend error: ${error}`) } - const sidebarProvider = new WebViewProvider('sidebar', context) - const panelProvider = new WebViewProvider('cli', context) - // Create a status bar item with a text and an icon - myStatusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 100, - ) - myStatusBarItem.text = 'Redis for VS Code' // Use the desired icon from the list - myStatusBarItem.tooltip = 'Click me for more info' - // myStatusBarItem.command = 'RedisForVSCode.openPage' // Command to execute on click - // Show the status bar item - // myStatusBarItem.show() + sidebarProvider = new WebViewProvider('sidebar', context) + panelProvider = new WebViewProvider('cli', context) context.subscriptions.push( - myStatusBarItem, vscode.window.registerWebviewViewProvider('ri-sidebar', sidebarProvider), vscode.window.registerWebviewViewProvider('ri-panel', panelProvider, { webviewOptions: { retainContextWhenHidden: true } }), @@ -212,13 +205,23 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('RedisForVSCode.updateSettingsDelimiter', (args) => { sidebarProvider.view?.webview.postMessage({ action: 'UpdateSettingsDelimiter', data: args.data }) }), + + vscode.commands.registerCommand('RedisForVSCode.showExtensionOutput', () => { + logger.show() + }), + + vscode.commands.registerCommand('RedisForVSCode.refreshDatabases', () => { + sidebarProvider.view?.webview.postMessage({ action: 'RefreshTree' }) + }), ) + + registerUriHandler() } export function deactivate() { try { getBackendGracefulShutdown() } catch (error) { - logger.log(`Deactivating error: ${error}`) + logger.logCore(`Deactivating error: ${error}`) } } diff --git a/src/lib/auth/auth.factory.ts b/src/lib/auth/auth.factory.ts new file mode 100644 index 00000000..874872a9 --- /dev/null +++ b/src/lib/auth/auth.factory.ts @@ -0,0 +1,5 @@ +import { AuthStrategy } from './auth.interface' +import { ServiceAuthStrategy } from './service.auth.strategy' + +export const createAuthStrategy = (): AuthStrategy => + ServiceAuthStrategy.getInstance() diff --git a/src/lib/auth/auth.handler.ts b/src/lib/auth/auth.handler.ts new file mode 100644 index 00000000..ee9a5d78 --- /dev/null +++ b/src/lib/auth/auth.handler.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode' +import { createAuthStrategy } from './auth.factory' +import { CloudAuthRequestOptions } from './models/cloud-auth-request' +import { CloudAuthStatus } from './models/cloud-auth-response' +import { logger } from '../../logger' +import { getBackendCloudAuthService } from '../../server/bootstrapBackend' +import { DEFAULT_SESSION_ID, DEFAULT_USER_ID, ViewId } from '../../constants' +import { wrapErrorMessageSensitiveData } from '../../utils/wrapErrorSensitiveData' +import { WebviewPanel } from '../../Webview' + +const authStrategy = createAuthStrategy() + +export const signInCloudOauth = async (options: CloudAuthRequestOptions) => { + try { + await authStrategy.initialize(getBackendCloudAuthService()) + const { url } = await authStrategy.getAuthUrl({ + sessionMetadata: { + sessionId: DEFAULT_SESSION_ID, + userId: DEFAULT_USER_ID, + }, + authOptions: { + ...options, + callback: getTokenCallbackFunction, + }, + }) + + await vscode.env.openExternal(vscode.Uri.parse(url)) + + return { + status: CloudAuthStatus.Succeed, + } + } catch (e) { + const error = wrapErrorMessageSensitiveData(e as Error) + getTokenCallbackFunction({ status: CloudAuthStatus.Failed, error }) + logger.logOAuth(error?.message) + return error + } +} + +export const getTokenCallbackFunction = (response: any) => { + WebviewPanel.getInstance({ viewId: ViewId.AddDatabase })?.postMessage({ + action: 'OAuthCallback', + data: response, + }) +} + +export const cloudOauthCallback = async (query: any) => { + try { + const result = await authStrategy.handleCallback(query) + + if (result.status === CloudAuthStatus.Failed) { + logger.logOAuth(result?.error?.message) + getTokenCallbackFunction(result) + } + } catch (e) { + const error = wrapErrorMessageSensitiveData(e as Error) + logger.logOAuth(error?.message) + getTokenCallbackFunction({ status: CloudAuthStatus.Failed, error }) + } +} diff --git a/src/lib/auth/auth.interface.ts b/src/lib/auth/auth.interface.ts new file mode 100644 index 00000000..dc96121f --- /dev/null +++ b/src/lib/auth/auth.interface.ts @@ -0,0 +1,10 @@ +import { UrlWithStringQuery } from 'url' +import { CloudAuthResponse } from './models/cloud-auth-response' + +export interface AuthStrategy { + initialize(cloudAuthService: any): Promise + shutdown(): Promise + getAuthUrl(options: any): Promise<{ url: string }> + handleCallback(query: UrlWithStringQuery): Promise + getBackendApp?(): any +} diff --git a/src/lib/auth/models/cloud-auth-request.ts b/src/lib/auth/models/cloud-auth-request.ts new file mode 100644 index 00000000..e6e30ed0 --- /dev/null +++ b/src/lib/auth/models/cloud-auth-request.ts @@ -0,0 +1,20 @@ +import { SessionMetadata } from './session' + +export enum CloudAuthIdpType { + Google = 'google', + GitHub = 'github', + Sso = 'sso', +} + +export interface CloudAuthRequestOptions { + strategy: CloudAuthIdpType + action?: string + data?: Record + callback?: Function +} + +export interface CloudAuthRequest extends CloudAuthRequestOptions { + idpType: CloudAuthIdpType + sessionMetadata: SessionMetadata + createdAt: Date +} diff --git a/src/lib/auth/models/cloud-auth-response.ts b/src/lib/auth/models/cloud-auth-response.ts new file mode 100644 index 00000000..1491d656 --- /dev/null +++ b/src/lib/auth/models/cloud-auth-response.ts @@ -0,0 +1,10 @@ +export enum CloudAuthStatus { + Succeed = 'succeed', + Failed = 'failed', +} + +export interface CloudAuthResponse { + status: CloudAuthStatus + message?: string + error?: any +} diff --git a/src/lib/auth/models/session.ts b/src/lib/auth/models/session.ts new file mode 100644 index 00000000..45dc5fda --- /dev/null +++ b/src/lib/auth/models/session.ts @@ -0,0 +1,12 @@ +export interface ISessionMetadata { + userId: string + sessionId: string + uniqueId?: string +} + +export interface SessionMetadata extends ISessionMetadata { + userId: string + sessionId: string + uniqueId?: string + correlationId?: string +} diff --git a/src/lib/auth/service.auth.strategy.ts b/src/lib/auth/service.auth.strategy.ts new file mode 100644 index 00000000..6ae2cd74 --- /dev/null +++ b/src/lib/auth/service.auth.strategy.ts @@ -0,0 +1,67 @@ +import { UrlWithStringQuery } from 'url' +import { AuthStrategy } from './auth.interface' +import { CloudAuthResponse, CloudAuthStatus } from './models/cloud-auth-response' +import { CustomLogger, logger } from '../../logger' + +export class ServiceAuthStrategy implements AuthStrategy { + private static instance: ServiceAuthStrategy + + private cloudAuthService!: any + + private initialized = false + + private logger: CustomLogger = logger + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() { } + + public static getInstance(): ServiceAuthStrategy { + if (!ServiceAuthStrategy.instance) { + ServiceAuthStrategy.instance = new ServiceAuthStrategy() + } + + return ServiceAuthStrategy.instance + } + + async initialize(cloudAuthService: any): Promise { + if (this.initialized) { + this.logger.logOAuth('Already initialized') + return + } + + this.logger.logOAuth('Initializing service auth') + try { + this.cloudAuthService = cloudAuthService + this.initialized = true + this.logger.logOAuth('Service auth initialized') + } catch (err) { + this.logger.logOAuth(`Initialization failed: ${err}`) + throw err + } + } + + async getAuthUrl(options: any): Promise<{ url: string }> { + this.logger.logOAuth('Getting auth URL') + const url = await this.cloudAuthService.getAuthorizationUrl( + options.sessionMetadata, + options.authOptions, + ) + this.logger.logOAuth('Auth URL obtained') + return { url } + } + + async handleCallback(query: UrlWithStringQuery): Promise { + this.logger.logOAuth('Handling callback') + if (this.cloudAuthService.isRequestInProgress(query)) { + this.logger.logOAuth('Request already in progress, skipping') + return { status: CloudAuthStatus.Succeed } + } + const result: CloudAuthResponse = await this.cloudAuthService.handleCallback(query) + this.logger.logOAuth('Callback handled query') + return result + } + + async shutdown(): Promise { + this.logger.logOAuth('Shutting down service auth') + } +} diff --git a/src/lib/update/checkVersionUpdate.ts b/src/lib/update/checkVersionUpdate.ts index 8400115c..1b4fc95c 100644 --- a/src/lib/update/checkVersionUpdate.ts +++ b/src/lib/update/checkVersionUpdate.ts @@ -7,7 +7,7 @@ export const checkVersionUpdate = async () => { const previousVersion = workspaceStateService.get('extensionVersion') const currentVersion = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version - logger.log(`Current version: ${currentVersion}`) + logger.logCore(`Current version: ${currentVersion}`) workspaceStateService.set('extensionVersion', currentVersion) const linkText = vscode.l10n.t('Release Notes') diff --git a/src/logger.ts b/src/logger.ts index d2aa5f6c..c9ab7904 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode' +import { EXTENSION_NAME } from './constants' -const channelName = 'Redis for VS Code' +const channelName = EXTENSION_NAME export class CustomLogger { private outputChannel: vscode.OutputChannel @@ -14,6 +15,18 @@ export class CustomLogger { this.outputChannel.appendLine(logMessage) } + logOAuth(message: string): void { + this.log(`[Service Auth] ${message}`) + } + + logServer(message: string): void { + this.log(`[Server] ${message}`) + } + + logCore(message: string): void { + this.log(`[Core] ${message}`) + } + show(): void { this.outputChannel.show() } diff --git a/src/server/bootstrapBackend.ts b/src/server/bootstrapBackend.ts index 79165ea6..702f8e3f 100644 --- a/src/server/bootstrapBackend.ts +++ b/src/server/bootstrapBackend.ts @@ -5,9 +5,16 @@ import * as fs from 'fs' import { setUIStorageField } from '../lib' import { CustomLogger } from '../logger' import { sleep } from '../utils' +import { AuthStrategy } from '../lib/auth/auth.interface' +import { createAuthStrategy } from '../lib/auth/auth.factory' +import { EXTENSION_NAME } from '../constants' let gracefulShutdown: Function let beApp: any +let beCloudAuthService: any + +// Create auth strategy after beApp is initialized +let authStrategy: AuthStrategy const backendPath = path.join(__dirname, '..', 'redis-backend', 'dist-minified') process.env.RI_DEFAULTS_DIR = path.join(backendPath, 'defaults') @@ -16,7 +23,7 @@ export async function startBackend(logger: CustomLogger): Promise { const appPort = process.env.RI_APP_PORT const port = await (await getPort.default(+appPort!)).toString() - logger.log(`Starting at port: ${port}`) + logger.logServer(`Starting at port: ${port}`) await setUIStorageField('appPort', port) @@ -25,20 +32,25 @@ export async function startBackend(logger: CustomLogger): Promise { vscode.window.showErrorMessage(errorMessage) console.debug(errorMessage) } else { - const message = vscode.window.setStatusBarMessage('Starting Redis for VS Code...') + const message = vscode.window.setStatusBarMessage(`Starting ${EXTENSION_NAME}...`) try { // @ts-ignore const server = await import('../../dist/redis-backend/dist-minified/main') - const { gracefulShutdown: gracefulShutdownFn, app: apiApp } = await server.default(port, logger) + const { gracefulShutdown: gracefulShutdownFn, app: apiApp, cloudAuthService } = await server.default(port, logger) gracefulShutdown = gracefulShutdownFn beApp = apiApp + beCloudAuthService = cloudAuthService // wait BE requests to take jsons from github await sleep(300) - logger.log('BE started') + + authStrategy = createAuthStrategy() + await authStrategy.initialize(cloudAuthService) + + logger.logServer('BE started') } catch (error) { - logger.log(`startBackendError: ${error}`) + logger.logServer(`[Error] startBackendError: ${error}`) } finally { message.dispose() } @@ -47,3 +59,4 @@ export async function startBackend(logger: CustomLogger): Promise { export const getBackendGracefulShutdown = () => gracefulShutdown?.() export const getBackendApp = () => beApp +export const getBackendCloudAuthService = () => beCloudAuthService diff --git a/src/server/bootstrapBackendE2E.ts b/src/server/bootstrapBackendE2E.ts index d91dffde..f805b16b 100644 --- a/src/server/bootstrapBackendE2E.ts +++ b/src/server/bootstrapBackendE2E.ts @@ -22,9 +22,9 @@ const defaultDirPath = path.join(backendPath, 'defaults') let PSinst: ChildProcessWithoutNullStreams export async function startBackendE2E(logger: CustomLogger): Promise { - logger.log('Starting backend in E2E') + logger.logServer('Starting backend in E2E') const port = await (await getPort.default(+appPort!)).toString() - logger.log(`Starting at port: ${port}`) + logger.logServer(`Starting at port: ${port}`) await setUIStorageField('appPort', port) @@ -108,7 +108,7 @@ function checkServerReady(logger: CustomLogger, port: string, callback: () => vo const checker = setInterval(async () => { try { const url = `${appUrl}:${port}/${appPrefix}/info` - logger.log(`checkServerReady: ${url}`) + logger.logServer(`checkServerReady: ${url}`) const res = await fetch(url) if (res.status === 200) { clearInterval(checker) diff --git a/src/utils.ts b/src/utils/handleMessage.ts similarity index 75% rename from src/utils.ts rename to src/utils/handleMessage.ts index 8c0bfd8c..7969f4a4 100644 --- a/src/utils.ts +++ b/src/utils/handleMessage.ts @@ -1,18 +1,6 @@ import * as vscode from 'vscode' -import { getUIStorageField, setUIStorageField } from './lib' -import { MAX_TITLE_KEY_LENGTH } from './constants' - -export const getNonce = () => { - let text = '' - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)) - } - return text -} - -export const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)) +import { getUIStorageField, setUIStorageField } from '../lib' +import { signInCloudOauth } from '../lib/auth/auth.handler' export const handleMessage = async (message: any = {}) => { switch (message.action) { @@ -27,6 +15,7 @@ export const handleMessage = async (message: any = {}) => { break case 'InformationMessage': vscode.window.showInformationMessage(message.data) + break case 'AddCli': vscode.commands.executeCommand('RedisForVSCode.addCli', message) @@ -50,9 +39,14 @@ export const handleMessage = async (message: any = {}) => { vscode.commands.executeCommand('RedisForVSCode.editKeyName', message.data) break case 'OpenAddDatabase': + if (message.data?.ssoFlow) { + await setUIStorageField('ssoFlow', message.data?.ssoFlow) + } + vscode.commands.executeCommand('RedisForVSCode.addDatabase') break case 'CloseAddDatabase': + console.debug('RedisForVSCode.addDatabaseClose, ', message.data) vscode.commands.executeCommand('RedisForVSCode.addDatabaseClose', message.data) break case 'CloseEditDatabase': @@ -75,16 +69,22 @@ export const handleMessage = async (message: any = {}) => { case 'CloseEula': vscode.commands.executeCommand('RedisForVSCode.closeEula', message) break + case 'RefreshDatabases': + vscode.commands.executeCommand('RedisForVSCode.refreshDatabases', message.data) + break case 'SaveAppInfo': await setUIStorageField('appInfo', message.data) break + + case 'CloudOAuth': + signInCloudOauth(message.data) + break + + case 'OpenExternalUrl': + await vscode.env.openExternal(vscode.Uri.parse(message.data)) + break + default: break } } - -export const truncateText = (text = '', maxLength = 0, separator = '...') => - (text.length >= maxLength ? text.slice(0, maxLength) + separator : text) - -export const getTitleForKey = (keyType: string, keyString: string): string => - `${keyType?.toLowerCase()}:${truncateText(keyString, MAX_TITLE_KEY_LENGTH)}` diff --git a/src/utils/handleUri.ts b/src/utils/handleUri.ts new file mode 100644 index 00000000..077d4dc4 --- /dev/null +++ b/src/utils/handleUri.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode' +import { cloudOauthCallback } from '../lib/auth/auth.handler' + +export async function registerUriHandler() { + vscode.window.registerUriHandler({ handleUri }) +} + +async function handleUri(uri: vscode.Uri) { + const query = Object.fromEntries(new URLSearchParams(uri.query)) + // const query = parse(uri.query) + + if (uri.path.startsWith('/cloud/oauth/callback')) { + await cloudOauthCallback(query) + return + } + + if (uri.path.startsWith('/databases/connect')) { + // sidebarProvider.view?.webview.postMessage({ action: 'oauthConnect' }) + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..e6954308 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './utils' +export * from './wrapErrorSensitiveData' +export * from './handleMessage' +export * from './handleUri' diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 00000000..fbd9298a --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,19 @@ +import { MAX_TITLE_KEY_LENGTH } from '../constants' + +export const getNonce = () => { + let text = '' + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)) + } + return text +} + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +export const truncateText = (text = '', maxLength = 0, separator = '...') => + (text.length >= maxLength ? text.slice(0, maxLength) + separator : text) + +export const getTitleForKey = (keyType: string, keyString: string): string => + `${keyType?.toLowerCase()}:${truncateText(keyString, MAX_TITLE_KEY_LENGTH)}` diff --git a/src/utils/wrapErrorSensitiveData.ts b/src/utils/wrapErrorSensitiveData.ts new file mode 100644 index 00000000..b613cea8 --- /dev/null +++ b/src/utils/wrapErrorSensitiveData.ts @@ -0,0 +1,18 @@ +/* eslint import/prefer-default-export: off */ +// Replacing sensitive data inside error message +// todo: split main.ts file and make proper structure +export const wrapErrorMessageSensitiveData = (e: Error) => { + const regexp = /(\/[^\s]*\/)|(\\[^\s]*\\)/gi + e.message = e.message.replace(regexp, (_match, unixPath, winPath): string => { + if (unixPath) { + return '*****/' + } + if (winPath) { + return '*****\\' + } + + return _match + }) + + return e +} diff --git a/src/webviews/src/actions/index.ts b/src/webviews/src/actions/index.ts index 46dbef32..fdd2e08e 100644 --- a/src/webviews/src/actions/index.ts +++ b/src/webviews/src/actions/index.ts @@ -4,3 +4,4 @@ export { setDatabaseAction } from './setDatabaseAction' export { processCliAction } from './processCliAction' export { refreshTreeAction } from './refreshTreeAction' export { addDatabaseAction } from './addDatabaseAction' +export { processOauthCallback } from './oauthCallback' diff --git a/src/webviews/src/actions/oauthCallback.ts b/src/webviews/src/actions/oauthCallback.ts new file mode 100644 index 00000000..db1beb52 --- /dev/null +++ b/src/webviews/src/actions/oauthCallback.ts @@ -0,0 +1,88 @@ +import { INFINITE_MESSAGES } from 'uiSrc/components' +import { CloudAuthStatus, CloudJobName, CloudJobStep, OAuthSocialAction, StorageItem } from 'uiSrc/constants' +import { CustomError } from 'uiSrc/interfaces' +import { CloudAuthResponse } from 'uiSrc/modules/oauth/interfaces' +import { localStorageService } from 'uiSrc/services' +import { createFreeDbJob, fetchUserInfo, useOAuthStore } from 'uiSrc/store' +import { getApiErrorMessage, parseCustomError, removeInfinityToast, showErrorInfinityToast, showInfinityToast } from 'uiSrc/utils' + +let isFlowInProgress = false + +export const processOauthCallback = ({ status, message = '', error }: CloudAuthResponse) => { + const { + ssoFlow, + isRecommendedSettings, + setSSOFlow, + setJob, + showOAuthProgress, + setSocialDialogState, + setOAuthCloudSource, + } = useOAuthStore.getState() + + const fetchUserInfoSuccess = (isSelectAccount: boolean) => { + if (isSelectAccount) return + + if (ssoFlow === OAuthSocialAction.SignIn) { + setSSOFlow(undefined) + removeInfinityToast() + return + } + + if (isRecommendedSettings) { + createFreeDbJob({ + name: CloudJobName.CreateFreeSubscriptionAndDatabase, + resources: { + isRecommendedSettings, + }, + onSuccessAction: () => { + showInfinityToast(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials).Inner) + }, + onFailAction: () => { + removeInfinityToast() + }, + }) + } + + showInfinityToast(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials).Inner) + + // TODO: SSO Autodiscovery import + // if (ssoFlowRef.current === OAuthSocialAction.Import) { + // dispatch(fetchSubscriptionsRedisCloud( + // null, + // true, + // () => { + // closeInfinityNotification() + // history.push(Pages.redisCloudSubscriptions) + // }, + // closeInfinityNotification, + // )) + // return + // } + + // dispatch(fetchPlans()) + } + + setJob({ id: '', name: CloudJobName.CreateFreeSubscriptionAndDatabase, status: '' }) + + if (status === CloudAuthStatus.Succeed) { + localStorageService.remove(StorageItem.OAuthJobId) + showOAuthProgress(true) + showInfinityToast(INFINITE_MESSAGES.AUTHENTICATING()?.Inner) + setSocialDialogState(null) + + fetchUserInfo(fetchUserInfoSuccess) + isFlowInProgress = true + } + + if (status === CloudAuthStatus.Failed) { + // don't do anything, because we are processing something + // covers situation when were made several clicks on the same time + if (isFlowInProgress) { + return + } + + const err = parseCustomError((error as CustomError) || message || '') + setOAuthCloudSource(null) + showErrorInfinityToast(getApiErrorMessage(err)) + } +} diff --git a/src/webviews/src/assets/icons/analysis.svg b/src/webviews/src/assets/icons/analysis.svg new file mode 100644 index 00000000..0e71566a --- /dev/null +++ b/src/webviews/src/assets/icons/analysis.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/bulk-upload.svg b/src/webviews/src/assets/icons/bulk-upload.svg new file mode 100644 index 00000000..fc4e8587 --- /dev/null +++ b/src/webviews/src/assets/icons/bulk-upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/bulk_actions.svg b/src/webviews/src/assets/icons/bulk_actions.svg new file mode 100644 index 00000000..0de97d13 --- /dev/null +++ b/src/webviews/src/assets/icons/bulk_actions.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/icons/champagne.svg b/src/webviews/src/assets/icons/champagne.svg new file mode 100644 index 00000000..08c2b759 --- /dev/null +++ b/src/webviews/src/assets/icons/champagne.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/check.svg b/src/webviews/src/assets/icons/check.svg new file mode 100644 index 00000000..6399d658 --- /dev/null +++ b/src/webviews/src/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/cheer.svg b/src/webviews/src/assets/icons/cheer.svg new file mode 100644 index 00000000..95baa5b9 --- /dev/null +++ b/src/webviews/src/assets/icons/cheer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/connection.svg b/src/webviews/src/assets/icons/connection.svg new file mode 100644 index 00000000..136a1511 --- /dev/null +++ b/src/webviews/src/assets/icons/connection.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/webviews/src/assets/icons/copilot.svg b/src/webviews/src/assets/icons/copilot.svg new file mode 100644 index 00000000..6d470ef7 --- /dev/null +++ b/src/webviews/src/assets/icons/copilot.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/icons/data-upload-bulk.svg b/src/webviews/src/assets/icons/data-upload-bulk.svg new file mode 100644 index 00000000..129402a0 --- /dev/null +++ b/src/webviews/src/assets/icons/data-upload-bulk.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/dislike.svg b/src/webviews/src/assets/icons/dislike.svg new file mode 100644 index 00000000..9bc0db83 --- /dev/null +++ b/src/webviews/src/assets/icons/dislike.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/formatter_dark.svg b/src/webviews/src/assets/icons/formatter_dark.svg new file mode 100644 index 00000000..82914a33 --- /dev/null +++ b/src/webviews/src/assets/icons/formatter_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/icons/formatter_light.svg b/src/webviews/src/assets/icons/formatter_light.svg new file mode 100644 index 00000000..6e579eb7 --- /dev/null +++ b/src/webviews/src/assets/icons/formatter_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/icons/github-white.svg b/src/webviews/src/assets/icons/github-white.svg new file mode 100644 index 00000000..8387bf76 --- /dev/null +++ b/src/webviews/src/assets/icons/github-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/group_mode.svg b/src/webviews/src/assets/icons/group_mode.svg new file mode 100644 index 00000000..19721941 --- /dev/null +++ b/src/webviews/src/assets/icons/group_mode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/webviews/src/assets/icons/help_illus.svg b/src/webviews/src/assets/icons/help_illus.svg new file mode 100644 index 00000000..3928b5fb --- /dev/null +++ b/src/webviews/src/assets/icons/help_illus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webviews/src/assets/icons/like.svg b/src/webviews/src/assets/icons/like.svg new file mode 100644 index 00000000..4eea43fc --- /dev/null +++ b/src/webviews/src/assets/icons/like.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/mobile_module_not_loaded.svg b/src/webviews/src/assets/icons/mobile_module_not_loaded.svg new file mode 100644 index 00000000..0508b514 --- /dev/null +++ b/src/webviews/src/assets/icons/mobile_module_not_loaded.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/module_not_loaded.svg b/src/webviews/src/assets/icons/module_not_loaded.svg new file mode 100644 index 00000000..e43e747c --- /dev/null +++ b/src/webviews/src/assets/icons/module_not_loaded.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/petard.svg b/src/webviews/src/assets/icons/petard.svg new file mode 100644 index 00000000..d1360b0e --- /dev/null +++ b/src/webviews/src/assets/icons/petard.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/webviews/src/assets/icons/raw_mode.svg b/src/webviews/src/assets/icons/raw_mode.svg new file mode 100644 index 00000000..ad006d7b --- /dev/null +++ b/src/webviews/src/assets/icons/raw_mode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/icons/recommendations_dark.svg b/src/webviews/src/assets/icons/recommendations_dark.svg new file mode 100644 index 00000000..99219b30 --- /dev/null +++ b/src/webviews/src/assets/icons/recommendations_dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/recommendations_light.svg b/src/webviews/src/assets/icons/recommendations_light.svg new file mode 100644 index 00000000..23264787 --- /dev/null +++ b/src/webviews/src/assets/icons/recommendations_light.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/redis_db_blue.svg b/src/webviews/src/assets/icons/redis_db_blue.svg new file mode 100644 index 00000000..27b74541 --- /dev/null +++ b/src/webviews/src/assets/icons/redis_db_blue.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/rocket.svg b/src/webviews/src/assets/icons/rocket.svg new file mode 100644 index 00000000..cf31c6b5 --- /dev/null +++ b/src/webviews/src/assets/icons/rocket.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/send.svg b/src/webviews/src/assets/icons/send.svg new file mode 100644 index 00000000..647c1676 --- /dev/null +++ b/src/webviews/src/assets/icons/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/icons/silent_mode.svg b/src/webviews/src/assets/icons/silent_mode.svg new file mode 100644 index 00000000..c4506771 --- /dev/null +++ b/src/webviews/src/assets/icons/silent_mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/snooze.svg b/src/webviews/src/assets/icons/snooze.svg new file mode 100644 index 00000000..36dd164a --- /dev/null +++ b/src/webviews/src/assets/icons/snooze.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/star.svg b/src/webviews/src/assets/icons/star.svg new file mode 100644 index 00000000..77fd1375 --- /dev/null +++ b/src/webviews/src/assets/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/stars.svg b/src/webviews/src/assets/icons/stars.svg new file mode 100644 index 00000000..21f47113 --- /dev/null +++ b/src/webviews/src/assets/icons/stars.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/webviews/src/assets/icons/treeview.svg b/src/webviews/src/assets/icons/treeview.svg new file mode 100644 index 00000000..c261414d --- /dev/null +++ b/src/webviews/src/assets/icons/treeview.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/webviews/src/assets/icons/user.svg b/src/webviews/src/assets/icons/user.svg new file mode 100644 index 00000000..ce303a48 --- /dev/null +++ b/src/webviews/src/assets/icons/user.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/webviews/src/assets/icons/user_in_circle.svg b/src/webviews/src/assets/icons/user_in_circle.svg new file mode 100644 index 00000000..fd2dd07d --- /dev/null +++ b/src/webviews/src/assets/icons/user_in_circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/webviews/src/assets/icons/vector.svg b/src/webviews/src/assets/icons/vector.svg new file mode 100644 index 00000000..c367a6e3 --- /dev/null +++ b/src/webviews/src/assets/icons/vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/icons/version.svg b/src/webviews/src/assets/icons/version.svg new file mode 100644 index 00000000..aa80c000 --- /dev/null +++ b/src/webviews/src/assets/icons/version.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/icons/warning.svg b/src/webviews/src/assets/icons/warning.svg new file mode 100644 index 00000000..626b6369 --- /dev/null +++ b/src/webviews/src/assets/icons/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/icons/welcome.svg b/src/webviews/src/assets/icons/welcome.svg new file mode 100644 index 00000000..0a87c74e --- /dev/null +++ b/src/webviews/src/assets/icons/welcome.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/oauth/aws_provider.svg b/src/webviews/src/assets/oauth/aws_provider.svg new file mode 100644 index 00000000..77f8792d --- /dev/null +++ b/src/webviews/src/assets/oauth/aws_provider.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/oauth/azure_provider.svg b/src/webviews/src/assets/oauth/azure_provider.svg new file mode 100644 index 00000000..b8bc11d1 --- /dev/null +++ b/src/webviews/src/assets/oauth/azure_provider.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/oauth/cloud.svg b/src/webviews/src/assets/oauth/cloud.svg new file mode 100644 index 00000000..e71cae2f --- /dev/null +++ b/src/webviews/src/assets/oauth/cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/oauth/cloud_centered.svg b/src/webviews/src/assets/oauth/cloud_centered.svg new file mode 100644 index 00000000..eb34fe4a --- /dev/null +++ b/src/webviews/src/assets/oauth/cloud_centered.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/oauth/cloud_color.svg b/src/webviews/src/assets/oauth/cloud_color.svg new file mode 100644 index 00000000..625ad0eb --- /dev/null +++ b/src/webviews/src/assets/oauth/cloud_color.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/webviews/src/assets/oauth/cloud_link.svg b/src/webviews/src/assets/oauth/cloud_link.svg new file mode 100644 index 00000000..f29b96e0 --- /dev/null +++ b/src/webviews/src/assets/oauth/cloud_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webviews/src/assets/oauth/confetti.svg b/src/webviews/src/assets/oauth/confetti.svg new file mode 100644 index 00000000..7bc627cc --- /dev/null +++ b/src/webviews/src/assets/oauth/confetti.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/webviews/src/assets/oauth/developer.svg b/src/webviews/src/assets/oauth/developer.svg new file mode 100644 index 00000000..34b6bc0a --- /dev/null +++ b/src/webviews/src/assets/oauth/developer.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/src/assets/oauth/github.svg b/src/webviews/src/assets/oauth/github.svg new file mode 100644 index 00000000..5837d04a --- /dev/null +++ b/src/webviews/src/assets/oauth/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/oauth/github_small.svg b/src/webviews/src/assets/oauth/github_small.svg new file mode 100644 index 00000000..605ba8f6 --- /dev/null +++ b/src/webviews/src/assets/oauth/github_small.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/oauth/google.svg b/src/webviews/src/assets/oauth/google.svg new file mode 100644 index 00000000..4cbd72cf --- /dev/null +++ b/src/webviews/src/assets/oauth/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/webviews/src/assets/oauth/google_provider.svg b/src/webviews/src/assets/oauth/google_provider.svg new file mode 100644 index 00000000..ac599299 --- /dev/null +++ b/src/webviews/src/assets/oauth/google_provider.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/webviews/src/assets/oauth/google_small.svg b/src/webviews/src/assets/oauth/google_small.svg new file mode 100644 index 00000000..109e3f36 --- /dev/null +++ b/src/webviews/src/assets/oauth/google_small.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/webviews/src/assets/oauth/hand.svg b/src/webviews/src/assets/oauth/hand.svg new file mode 100644 index 00000000..158b694c --- /dev/null +++ b/src/webviews/src/assets/oauth/hand.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/oauth/redisearch.svg b/src/webviews/src/assets/oauth/redisearch.svg new file mode 100644 index 00000000..df9b0121 --- /dev/null +++ b/src/webviews/src/assets/oauth/redisearch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/oauth/rejson.svg b/src/webviews/src/assets/oauth/rejson.svg new file mode 100644 index 00000000..98f01ecb --- /dev/null +++ b/src/webviews/src/assets/oauth/rejson.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/oauth/rocket.svg b/src/webviews/src/assets/oauth/rocket.svg new file mode 100644 index 00000000..50919c6d --- /dev/null +++ b/src/webviews/src/assets/oauth/rocket.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/webviews/src/assets/oauth/sso.svg b/src/webviews/src/assets/oauth/sso.svg new file mode 100644 index 00000000..7d9c840d --- /dev/null +++ b/src/webviews/src/assets/oauth/sso.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/assets/oauth/stars.svg b/src/webviews/src/assets/oauth/stars.svg new file mode 100644 index 00000000..c66438c5 --- /dev/null +++ b/src/webviews/src/assets/oauth/stars.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webviews/src/components/index.ts b/src/webviews/src/components/index.ts index e571b88d..490f98e0 100644 --- a/src/webviews/src/components/index.ts +++ b/src/webviews/src/components/index.ts @@ -17,6 +17,8 @@ export { AutoRefresh } from './auto-refresh/AutoRefresh' export * from './database-form' export * from './consents-option' export * from './consents-privacy' +export { INFINITE_MESSAGES } from './notifications/infinite-messages' +export { GlobalToasts } from './notifications/global-toasts' export type { SuperSelectOption } from './super-select/SuperSelect' export type { MultiSelectOption } from './multi-select/MultiSelect' diff --git a/src/webviews/src/components/notifications/global-toasts/GlobalToasts.tsx b/src/webviews/src/components/notifications/global-toasts/GlobalToasts.tsx new file mode 100644 index 00000000..67a9bf4c --- /dev/null +++ b/src/webviews/src/components/notifications/global-toasts/GlobalToasts.tsx @@ -0,0 +1,11 @@ +import React, { FC } from 'react' +import { Flip, ToastContainer } from 'react-toastify' +import styles from './styles.module.scss' + +export const GlobalToasts: FC = () => ( + +) diff --git a/src/webviews/src/components/notifications/global-toasts/index.ts b/src/webviews/src/components/notifications/global-toasts/index.ts new file mode 100644 index 00000000..fb6482fb --- /dev/null +++ b/src/webviews/src/components/notifications/global-toasts/index.ts @@ -0,0 +1 @@ +export { GlobalToasts } from './GlobalToasts' diff --git a/src/webviews/src/components/notifications/global-toasts/styles.module.scss b/src/webviews/src/components/notifications/global-toasts/styles.module.scss new file mode 100644 index 00000000..7e52f290 --- /dev/null +++ b/src/webviews/src/components/notifications/global-toasts/styles.module.scss @@ -0,0 +1,14 @@ +.toastsContainer { + @apply flex items-start rounded-none max-w-[300px] p-4; + background-color: var(--vscode-sideBarTitle-background); + border: 1px solid var(--vscode-inputOption-activeBorder); + color: var(--vscode-editor-foreground); +} + +:global(.Toastify__close-button) { + color: var(--vscode-editor-foreground) !important; +} + +:global(.Toastify__toast--error) { + border: 1px solid var(--vscode-inputValidation-errorBorder); +} diff --git a/src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.spec.tsx b/src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.spec.tsx new file mode 100644 index 00000000..d3514d63 --- /dev/null +++ b/src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { CloudJobName } from 'uiSrc/constants' +import { fireEvent, render, screen } from 'testSrc/helpers' + +import { INFINITE_MESSAGES } from './InfiniteMessages' + +describe('INFINITE_MESSAGES', () => { + describe('SUCCESS_CREATE_DB', () => { + it('should render message', () => { + const { Inner } = INFINITE_MESSAGES.SUCCESS_CREATE_DB(CloudJobName.CreateFreeSubscriptionAndDatabase, vi.fn()) + expect(render(<>{Inner})).toBeTruthy() + }) + + it('should call onSuccess', () => { + const onSuccess = vi.fn() + const { Inner } = INFINITE_MESSAGES.SUCCESS_CREATE_DB(CloudJobName.CreateFreeSubscriptionAndDatabase, onSuccess) + render(<>{Inner}) + + // fireEvent.click(screen.getByTestId('notification-connect-db')) + fireEvent.mouseUp(screen.getByTestId('success-create-db-notification')) + fireEvent.mouseDown(screen.getByTestId('success-create-db-notification')) + + // expect(onSuccess).toBeCalled() + }) + }) + describe('AUTHENTICATING', () => { + it('should render message', () => { + const { Inner } = INFINITE_MESSAGES.AUTHENTICATING() + expect(render(<>{Inner})).toBeTruthy() + }) + }) + describe('PENDING_CREATE_DB', () => { + it('should render message', () => { + const { Inner } = INFINITE_MESSAGES.PENDING_CREATE_DB() + expect(render(<>{Inner})).toBeTruthy() + }) + }) + describe('DATABASE_EXISTS', () => { + it('should render message', () => { + const { Inner } = INFINITE_MESSAGES.DATABASE_EXISTS(vi.fn()) + expect(render(<>{Inner})).toBeTruthy() + }) + + it('should call onSuccess', () => { + const onSuccess = vi.fn() + const { Inner } = INFINITE_MESSAGES.DATABASE_EXISTS(onSuccess) + render(<>{Inner}) + + fireEvent.click(screen.getByTestId('import-db-sso-btn')) + fireEvent.mouseUp(screen.getByTestId('database-exists-notification')) + fireEvent.mouseDown(screen.getByTestId('database-exists-notification')) + + expect(onSuccess).toBeCalled() + }) + + it('should call onCancel', () => { + const onSuccess = vi.fn() + const onCancel = vi.fn() + const { Inner } = INFINITE_MESSAGES.DATABASE_EXISTS(onSuccess, onCancel) + render(<>{Inner}) + + fireEvent.click(screen.getByTestId('cancel-import-db-sso-btn')) + fireEvent.mouseUp(screen.getByTestId('database-exists-notification')) + fireEvent.mouseDown(screen.getByTestId('database-exists-notification')) + + expect(onCancel).toBeCalled() + }) + }) + describe('SUBSCRIPTION_EXISTS', () => { + it('should render message', () => { + const { Inner } = INFINITE_MESSAGES.SUBSCRIPTION_EXISTS(vi.fn()) + expect(render(<>{Inner})).toBeTruthy() + }) + + it('should call onSuccess', () => { + const onSuccess = vi.fn() + const { Inner } = INFINITE_MESSAGES.SUBSCRIPTION_EXISTS(onSuccess) + render(<>{Inner}) + + fireEvent.click(screen.getByTestId('create-subscription-sso-btn')) + fireEvent.mouseUp(screen.getByTestId('subscription-exists-notification')) + fireEvent.mouseDown(screen.getByTestId('subscription-exists-notification')) + + expect(onSuccess).toBeCalled() + }) + + it('should call onCancel', () => { + const onSuccess = vi.fn() + const onCancel = vi.fn() + const { Inner } = INFINITE_MESSAGES.SUBSCRIPTION_EXISTS(onSuccess, onCancel) + render(<>{Inner}) + + fireEvent.click(screen.getByTestId('cancel-create-subscription-sso-btn')) + fireEvent.mouseUp(screen.getByTestId('subscription-exists-notification')) + fireEvent.mouseDown(screen.getByTestId('subscription-exists-notification')) + + expect(onCancel).toBeCalled() + }) + }) +}) diff --git a/src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.tsx b/src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.tsx new file mode 100644 index 00000000..275729a6 --- /dev/null +++ b/src/webviews/src/components/notifications/infinite-messages/InfiniteMessages.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' +import * as l10n from '@vscode/l10n' + +import { Spacer, Spinner } from 'uiSrc/ui' +import { CloudJobName, CloudJobStep } from 'uiSrc/constants' +import { Maybe } from 'uiSrc/interfaces' +import ChampagneIcon from 'uiSrc/assets/icons/champagne.svg?react' + +export enum InfiniteMessagesIds { + oAuthProgress = 'oAuthProgress', + oAuthSuccess = 'oAuthSuccess', + autoCreateDb = 'autoCreateDb', + databaseExists = 'databaseExists', + subscriptionExists = 'subscriptionExists', + appUpdateAvailable = 'appUpdateAvailable', + pipelineDeploySuccess = 'pipelineDeploySuccess', +} + +export const INFINITE_MESSAGES = { + AUTHENTICATING: () => ({ + id: InfiniteMessagesIds.oAuthProgress, + Inner: ( +
+ +
+
+ {l10n.t('Authenticating…')} +
+ +
+ {l10n.t('This may take several seconds, but it is totally worth it!')} +
+
+
+ ), + }), + PENDING_CREATE_DB: (step?: CloudJobStep) => ({ + id: InfiniteMessagesIds.oAuthProgress, + Inner: ( +
+ +
+ + { (step === CloudJobStep.Credentials || !step) && l10n.t('Processing Cloud API keys…')} + { step === CloudJobStep.Subscription && l10n.t('Processing Cloud subscriptions…')} + { step === CloudJobStep.Database && l10n.t('Creating a free Cloud database…')} + { step === CloudJobStep.Import && l10n.t('Importing a free Cloud database…')} + + +
+ {l10n.t('This may take several minutes, but it is totally worth it!')} +
+ {/*
+ You can continue working in Redis Insight, and we will notify you once done. +
*/} +
+
+ ), + }), + SUCCESS_CREATE_DB: (jobName: Maybe, onSuccess?: () => void) => { + const withFeed = jobName + && [CloudJobName.CreateFreeDatabase, CloudJobName.CreateFreeSubscriptionAndDatabase].includes(jobName) + const text = `${l10n.t('You can now use your Redis Stack database in Redis Cloud')}${withFeed ? l10n.t(' with pre-loaded sample data') : ''}.` + return ({ + id: InfiniteMessagesIds.oAuthSuccess, + Inner: ( +
{ e.preventDefault() }} + onMouseUp={(e) => { e.preventDefault() }} + data-testid="success-create-db-notification" + > +
+
+ +
+
+
{l10n.t('Congratulations!')}
+ +
+ {text} + + {l10n.t('Notice: ')}{l10n.t('the database will be deleted after 15 days of inactivity.')} +
+ {/* onSuccess()} + data-testid="notification-connect-db" + > + {l10n.t('Connect')} + */} +
+
+
+ ), + }) + }, + DATABASE_EXISTS: (onSuccess?: () => void, onClose?: () => void) => ({ + id: InfiniteMessagesIds.databaseExists, + Inner: ( +
{ e.preventDefault() }} + onMouseUp={(e) => { e.preventDefault() }} + data-testid="database-exists-notification" + className="flex" + > + +
+
{l10n.t('You already have a free Redis Cloud subscription.')}
+ +
+ {l10n.t('Do you want to import your existing database into Redis Insight?')} +
+ +
+
+ onSuccess?.()} + data-testid="import-db-sso-btn" + > + {l10n.t('Import')} + +
+
+ onClose?.()} + data-testid="cancel-import-db-sso-btn" + > + {l10n.t('Cancel')} + +
+
+
+
+ ), + }), + SUBSCRIPTION_EXISTS: (onSuccess?: () => void, onClose?: () => void) => ({ + id: InfiniteMessagesIds.subscriptionExists, + Inner: ( +
{ e.preventDefault() }} + onMouseUp={(e) => { e.preventDefault() }} + className="flex" + data-testid="subscription-exists-notification" + > + +
+
{l10n.t('Your subscription does not have a free Redis Cloud database.')}
+ +
+ {l10n.t('Do you want to create a free database in your existing subscription?')} +
+ +
+ onSuccess?.()} + data-testid="create-subscription-sso-btn" + > + {l10n.t('Create')} + + onClose?.()} + data-testid="cancel-create-subscription-sso-btn" + > + {l10n.t('Cancel')} + +
+
+
+ ), + }), +} diff --git a/src/webviews/src/components/notifications/infinite-messages/index.ts b/src/webviews/src/components/notifications/infinite-messages/index.ts new file mode 100644 index 00000000..41cb77c9 --- /dev/null +++ b/src/webviews/src/components/notifications/infinite-messages/index.ts @@ -0,0 +1 @@ +export { InfiniteMessagesIds, INFINITE_MESSAGES } from './InfiniteMessages' diff --git a/src/webviews/src/constants/cloud/oauth.ts b/src/webviews/src/constants/cloud/oauth.ts new file mode 100644 index 00000000..b64ff51c --- /dev/null +++ b/src/webviews/src/constants/cloud/oauth.ts @@ -0,0 +1,15 @@ +export enum CloudSubscriptionPlanProvider { + AWS = 'AWS', + GCP = 'GCP', + Azure = 'Azure', +} + +export enum CloudSubscriptionType { + Flexible = 'flexible', + Fixed = 'fixed', +} + +export enum CloudAuthStatus { + Succeed = 'succeed', + Failed = 'failed', +} diff --git a/src/webviews/src/constants/cloud/source.ts b/src/webviews/src/constants/cloud/source.ts index 89bc133f..9d2c8623 100644 --- a/src/webviews/src/constants/cloud/source.ts +++ b/src/webviews/src/constants/cloud/source.ts @@ -1,6 +1,11 @@ +import AzureIcon from 'uiSrc/assets/oauth/azure_provider.svg?react' +import AWSIcon from 'uiSrc/assets/oauth/aws_provider.svg?react' +import GoogleIcon from 'uiSrc/assets/oauth/google_provider.svg?react' + export enum OAuthSocialSource { Browser = 'browser', ListOfDatabases = 'list of databases', + DatabaseConnectionList = 'database connection list', WelcomeScreen = 'welcome screen', BrowserContentMenu = 'browser content menu', BrowserFiltering = 'browser filtering', @@ -20,4 +25,79 @@ export enum OAuthSocialSource { DiscoveryForm = 'discovery form', UserProfile = 'user profile', AiChat = 'ai chat', + NavigationMenu = 'navigation menu', + AddDbForm = 'add db form', } + +export enum CloudJobStatus { + Initializing = 'initializing', + Running = 'running', + Finished = 'finished', + Failed = 'failed', +} + +export enum CloudJobName { + CreateFreeSubscriptionAndDatabase = 'CREATE_FREE_SUBSCRIPTION_AND_DATABASE', + CreateFreeDatabase = 'CREATE_FREE_DATABASE', + CreateFreeSubscription = 'CREATE_FREE_SUBSCRIPTION', + ImportFreeDatabase = 'IMPORT_FREE_DATABASE', + WaitForActiveDatabase = 'WAIT_FOR_ACTIVE_DATABASE', + WaitForActiveSubscription = 'WAIT_FOR_ACTIVE_SUBSCRIPTION', + WaitForTask = 'WAIT_FOR_TASK', + Unknown = 'UNKNOWN', +} + +export enum CloudJobStep { + Credentials = 'credentials', + Subscription = 'subscription', + Database = 'database', + Import = 'import', +} + +export enum OAuthSocialAction { + Create = 'create', + Import = 'import', + SignIn = 'signIn', +} + +export enum OAuthStrategy { + Google = 'google', + GitHub = 'github', + SSO = 'sso', +} + +export enum CloudSsoUtmCampaign { + ListOfDatabases = 'list_of_databases', + Workbench = 'redisinsight_workbench', + WelcomeScreen = 'welcome_screen', + BrowserSearch = 'redisinsight_browser_search', + BrowserOverview = 'redisinsight_browser_overview', + BrowserFilter = 'browser_filter', + Tutorial = 'tutorial', + AutoDiscovery = 'auto_discovery', + Copilot = 'copilot', + UserProfile = 'user_account', + Settings = 'settings', + Unknown = 'other', +} + +export enum OAuthProvider { + AWS = 'AWS', + Azure = 'Azure', + Google = 'GCP', +} + +export const OAuthProviders = [{ + id: OAuthProvider.AWS, + icon: AWSIcon, + label: 'Amazon Web Services', + // className: styles.awsIcon, +}, { + id: OAuthProvider.Google, + icon: GoogleIcon, + label: 'Google Cloud', +}, { + id: OAuthProvider.Azure, + icon: AzureIcon, + label: 'Microsoft Azure', +}] diff --git a/src/webviews/src/constants/core/apiErrors.ts b/src/webviews/src/constants/core/apiErrors.ts index 19531741..1e12af2b 100644 --- a/src/webviews/src/constants/core/apiErrors.ts +++ b/src/webviews/src/constants/core/apiErrors.ts @@ -12,3 +12,10 @@ export const ApiEncryptionErrors: string[] = [ ApiErrors.KeytarEncryption, ApiErrors.KeytarDecryption, ] + +export enum ApiStatusCode { + Unauthorized = 401, + BadRequest = 400, + Forbidden = 403, + Timeout = 408, +} diff --git a/src/webviews/src/constants/core/customErrorCodes.ts b/src/webviews/src/constants/core/customErrorCodes.ts new file mode 100644 index 00000000..ef6d3960 --- /dev/null +++ b/src/webviews/src/constants/core/customErrorCodes.ts @@ -0,0 +1,61 @@ +export enum CustomErrorCodes { + // General [10000, 10999] + WindowUnauthorized = 10_001, + + // Cloud API [11001, 11099] + CloudApiInternalServerError = 11_000, + CloudApiUnauthorized = 11_001, + CloudApiForbidden = 11_002, + CloudApiBadRequest = 11_003, + CloudApiNotFound = 11_004, + CloudOauthMisconfiguration = 11_005, + CloudOauthGithubEmailPermission = 11_006, + CloudOauthUnknownAuthorizationRequest = 11_007, + CloudOauthUnexpectedError = 11_008, + CloudOauthSsoUnsupportedEmail = 11_011, + CloudCapiUnauthorized = 11_021, + CloudCapiKeyUnauthorized = 11_022, + + // Cloud Job errors [11100, 11199] + CloudJobUnexpectedError = 11_100, + CloudJobAborted = 11_101, + CloudJobUnsupported = 11_102, + CloudTaskProcessingError = 11_103, + CloudTaskNoResourceId = 11_104, + CloudSubscriptionIsInTheFailedState = 11_105, + CloudSubscriptionIsInUnexpectedState = 11_106, + CloudDatabaseIsInTheFailedState = 11_107, + CloudDatabaseAlreadyExistsFree = 11_108, + CloudDatabaseIsInUnexpectedState = 11_109, + CloudPlanUnableToFindFree = 11_110, + CloudSubscriptionUnableToDetermine = 11_111, + CloudTaskNotFound = 11_112, + CloudJobNotFound = 11_113, + CloudSubscriptionAlreadyExistsFree = 11_114, + + // General database errors [11200, 11299] + DatabaseAlreadyExists = 11_200, + + // AI errors [11300, 11399] + ConvAiInternalServerError = 11_300, + ConvAiUnauthorized = 11_301, + ConvAiForbidden = 11_302, + ConvAiBadRequest = 11_303, + ConvAiNotFound = 11_304, + + QueryAiInternalServerError = 11_351, + QueryAiUnauthorized = 11_351, + QueryAiForbidden = 11_352, + QueryAiBadRequest = 11_353, + QueryAiNotFound = 11_354, + + AiQueryRateLimitRequest = 11_360, + AiQueryRateLimitToken = 11_361, + AiQueryRateLimitMaxTokens = 11362, + + GeneralAiUnexpectedError = 11_391, + + // RDI errors [11400, 11499] + RdiDeployPipelineFailure = 11_401, + RdiValidationError = 11_404, +} diff --git a/src/webviews/src/constants/environment/environment.ts b/src/webviews/src/constants/environment/environment.ts index eecdb32a..dbfee091 100644 --- a/src/webviews/src/constants/environment/environment.ts +++ b/src/webviews/src/constants/environment/environment.ts @@ -5,6 +5,19 @@ export const BASE_APP_URL = import.meta.env.RI_BASE_APP_URL || 'http://localhost export const APP_PORT = toNumber(window.ri?.appPort) || import.meta.env.RI_APP_PORT || 5541 export const APP_PREFIX = import.meta.env.RI_APP_PREFIX || 'api' +const isDevelopment = import.meta.env.NODE_ENV === 'development' +const hostedApiBaseUrl = import.meta.env.RI_HOSTED_API_BASE_URL + // browser export const SCAN_TREE_COUNT_DEFAULT = import.meta.env.RI_SCAN_TREE_COUNT || 10_000 export const SCAN_COUNT_DEFAULT = import.meta.env.RI_SCAN_COUNT_DEFAULT || 500 + +export const getBaseApiUrl = () => { + if (hostedApiBaseUrl) { + return hostedApiBaseUrl + } + + return (!isDevelopment + ? window.location.origin + : `${BASE_APP_URL}:${APP_PORT}`) +} diff --git a/src/webviews/src/constants/external/links.ts b/src/webviews/src/constants/external/links.ts index b4b8c11d..cd8e2be7 100644 --- a/src/webviews/src/constants/external/links.ts +++ b/src/webviews/src/constants/external/links.ts @@ -1,9 +1,12 @@ +import { CloudSsoUtmCampaign, OAuthSocialSource } from '../cloud/source' + export const EXTERNAL_LINKS = { riAppDownload: 'https://redis.io/insight/', jsonModule: 'https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/json/', tryFree: 'https://redis.io/try-free/', githubRepo: 'https://github.com/RedisInsight/Redis-for-VS-Code/', githubIssues: 'https://github.com/RedisInsight/Redis-for-VS-Code/issues/', + cloudConsole: 'https://cloud.redis.io/#/databases/', } export const UTM_CAMPAIGNS = { @@ -12,3 +15,21 @@ export const UTM_CAMPAIGNS = { redisjson: 'redisinsight_redisjson', redisLatest: 'redisinsight_redis_latest', } + +export const UTM_CAMPAINGS: Record = { + [OAuthSocialSource.Tutorials]: 'redisinsight_tutorials', + [OAuthSocialSource.BrowserSearch]: 'redisinsight_browser_search', + [OAuthSocialSource.Workbench]: 'redisinsight_workbench', + [CloudSsoUtmCampaign.BrowserFilter]: 'browser_filter', + [OAuthSocialSource.EmptyDatabasesList]: 'empty_db_list', + [OAuthSocialSource.AddDbForm]: 'add_db_form', + PubSub: 'pub_sub', + Main: 'main', +} + +export const UTM_MEDIUMS = { + App: 'app', + Main: 'main', + Rdi: 'rdi', + Recommendation: 'recommendation', +} diff --git a/src/webviews/src/constants/index.ts b/src/webviews/src/constants/index.ts index eafe9146..5385971f 100644 --- a/src/webviews/src/constants/index.ts +++ b/src/webviews/src/constants/index.ts @@ -32,3 +32,7 @@ export * from './window/popover' export * from './database/commandsVersions' export * from './cloud/source' export * from './monaco/monacoLanguage' +export * from './core/customErrorCodes' +export * from './cloud/oauth' +export * from './sockets/socketErrors' +export * from './sockets/socketEvents' diff --git a/src/webviews/src/constants/sockets/socketErrors.ts b/src/webviews/src/constants/sockets/socketErrors.ts new file mode 100644 index 00000000..ab9498f6 --- /dev/null +++ b/src/webviews/src/constants/sockets/socketErrors.ts @@ -0,0 +1,3 @@ +export enum SocketErrors { + TransportError = 'TransportError', +} diff --git a/src/webviews/src/constants/sockets/socketEvents.ts b/src/webviews/src/constants/sockets/socketEvents.ts new file mode 100644 index 00000000..41c3ae9c --- /dev/null +++ b/src/webviews/src/constants/sockets/socketEvents.ts @@ -0,0 +1,13 @@ +export enum SocketEvent { + Connect = 'connect', + Disconnect = 'disconnect', + ConnectionError = 'connect_error', +} + +export enum SocketFeaturesEvent { + Features = 'features', +} + +export enum CloudJobEvents { + Monitor = 'cloud:job:monitor', +} diff --git a/src/webviews/src/constants/vscode/vscode.ts b/src/webviews/src/constants/vscode/vscode.ts index aa679e85..e22e18d5 100644 --- a/src/webviews/src/constants/vscode/vscode.ts +++ b/src/webviews/src/constants/vscode/vscode.ts @@ -28,6 +28,10 @@ export enum VscodeMessageAction { ShowEula = 'ShowEula', CloseEula = 'CloseEula', UpdateDatabaseInList = 'UpdateDatabaseInList', + CloudOAuth = 'CloudOAuth', + OAuthCallback = 'OAuthCallback', + RefreshDatabases = 'RefreshDatabases', + OpenExternalUrl = 'OpenExternalUrl', } export enum VscodeStateItem { diff --git a/src/webviews/src/index.tsx b/src/webviews/src/index.tsx index c0c49051..be3c2789 100644 --- a/src/webviews/src/index.tsx +++ b/src/webviews/src/index.tsx @@ -21,8 +21,10 @@ import { setDatabaseAction, refreshTreeAction, addDatabaseAction, + processOauthCallback, } from './actions' import { MonacoLanguages } from './components' +import { CloudAuthResponse } from './modules/oauth/interfaces' import './styles/main.scss' import '../vscode.css' @@ -58,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => { refreshTreeAction(message) break case VscodeMessageAction.UpdateDatabaseInList: - useDatabasesStore.getState().setDatabaseToList(message.data?.database) + useDatabasesStore.getState().setDatabaseToList(message.data?.database!) break case VscodeMessageAction.AddDatabase: addDatabaseAction(message) @@ -81,6 +83,11 @@ document.addEventListener('DOMContentLoaded', () => { case VscodeMessageAction.AddCli: processCliAction(message) break + + // OAuth + case VscodeMessageAction.OAuthCallback: + processOauthCallback(message.data as CloudAuthResponse) + break default: break } diff --git a/src/webviews/src/interfaces/core/app.ts b/src/webviews/src/interfaces/core/app.ts index 16d1865c..db71604e 100644 --- a/src/webviews/src/interfaces/core/app.ts +++ b/src/webviews/src/interfaces/core/app.ts @@ -1,3 +1,5 @@ +import { AxiosError } from 'axios' + export interface CustomError { error: string message: string @@ -25,3 +27,14 @@ export type RedisResponseBuffer = { export type RedisString = string | RedisResponseBuffer export type UintArray = number[] | Uint8Array + +export interface ErrorOptions { + message: string | JSX.Element + code?: string + config?: object + request?: object + response?: object +} + +export interface EnhancedAxiosError extends AxiosError { +} diff --git a/src/webviews/src/interfaces/vscode/api.ts b/src/webviews/src/interfaces/vscode/api.ts index 362b6916..3a173759 100644 --- a/src/webviews/src/interfaces/vscode/api.ts +++ b/src/webviews/src/interfaces/vscode/api.ts @@ -1,6 +1,7 @@ -import { AllKeyTypes, KeyTypes, SelectedKeyActionType, VscodeMessageAction } from 'uiSrc/constants' +import { AllKeyTypes, KeyTypes, OAuthSocialAction, OAuthStrategy, SelectedKeyActionType, VscodeMessageAction } from 'uiSrc/constants' import { Database } from 'uiSrc/store' import { AppInfoStore, GetAppSettingsResponse } from 'uiSrc/store/hooks/use-app-info-store/interface' +import { CloudAuthResponse } from 'uiSrc/modules/oauth/interfaces' import { RedisString } from '../core/app' export interface IVSCodeApi { @@ -26,19 +27,47 @@ export interface SelectKeyAction { } } +export interface OpenExternalUrlAction { + action: VscodeMessageAction.OpenExternalUrl + data: string +} + +export interface OAuthCallbackAction { + action: VscodeMessageAction.OAuthCallback + data: CloudAuthResponse +} + +export interface CloudOAuthAction { + action: VscodeMessageAction.CloudOAuth + data: { + strategy: OAuthStrategy + action: string + data?: object + } +} + export interface SetDatabaseAction { action: VscodeMessageAction.EditDatabase | VscodeMessageAction.AddKey | VscodeMessageAction.CloseEditDatabase | VscodeMessageAction.RefreshTree | VscodeMessageAction.SetDatabase - | VscodeMessageAction.CloseAddDatabase | VscodeMessageAction.AddDatabase | VscodeMessageAction.UpdateDatabaseInList data: { database: Database } } + +export interface DatabaseAction { + action: VscodeMessageAction.RefreshDatabases + | VscodeMessageAction.CloseAddDatabase + | VscodeMessageAction.OpenAddDatabase + data?: { + database?: Database + ssoFlow?: OAuthSocialAction + } +} export interface CliAction { action: VscodeMessageAction.AddCli data: { @@ -75,10 +104,6 @@ export interface SelectedKeyCloseAction { action: VscodeMessageAction.CloseKey } -export interface NoDataAction { - action: VscodeMessageAction.OpenAddDatabase -} - export interface CloseAddKeyAction { action: VscodeMessageAction.CloseAddKey data: RedisString @@ -112,7 +137,6 @@ export type PostMessage = SetDatabaseAction | InformationMessageAction | SelectedKeyAction | - NoDataAction | CloseAddKeyAction | UpdateSettingsAction | UpdateSettingsDelimiterAction | @@ -121,4 +145,8 @@ export type PostMessage = ShowEulaAction | CloseEulaAction | ResetSelectedKeyAction | - CliAction + CliAction | + CloudOAuthAction | + DatabaseAction | + OpenExternalUrlAction | + OAuthCallbackAction diff --git a/src/webviews/src/mocks/data/oauth.ts b/src/webviews/src/mocks/data/oauth.ts new file mode 100644 index 00000000..0e956dcc --- /dev/null +++ b/src/webviews/src/mocks/data/oauth.ts @@ -0,0 +1,21 @@ +export const OAUTH_CLOUD_CAPI_KEYS_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, + }, +] + +export const MOCK_OAUTH_USER_PROFILE = { + id: 1, + name: 'Bill Russell', + accounts: [ + { id: 1, name: 'Bill R' }, + { id: 2, name: 'Bill R 2' }, + ], + currentAccountId: 1, +} + +export const MOCK_OAUTH_SSO_EMAIL = 'sso@mail.com' diff --git a/src/webviews/src/modules/common-app-subscription/CommonAppSubscription.spec.tsx b/src/webviews/src/modules/common-app-subscription/CommonAppSubscription.spec.tsx new file mode 100644 index 00000000..954a2fb0 --- /dev/null +++ b/src/webviews/src/modules/common-app-subscription/CommonAppSubscription.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import MockedSocket from 'socket.io-mock' +import socketIO from 'socket.io-client' +import { Mock } from 'vitest' + +import { cleanup, render } from 'testSrc/helpers' +import { CommonAppSubscription } from './CommonAppSubscription' + +let socket: typeof MockedSocket +beforeEach(() => { + cleanup() + socket = new MockedSocket(); + (socketIO as Mock).mockReturnValue(socket) +}) + +vi.mock('socket.io-client') + +vi.mock('uiSrc/slices/instances/instances', async () => ({ + ...await (vi.importActual('uiSrc/slices/instances/instances')), + connectedInstanceSelector: vi.fn().mockReturnValue({ + id: vi.fn().mockReturnValue(''), + connectionType: 'STANDALONE', + db: 0, + }), +})) + +describe('CommonAppSubscription', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/src/webviews/src/modules/common-app-subscription/CommonAppSubscription.tsx b/src/webviews/src/modules/common-app-subscription/CommonAppSubscription.tsx new file mode 100644 index 00000000..4160e0df --- /dev/null +++ b/src/webviews/src/modules/common-app-subscription/CommonAppSubscription.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import { io, Socket } from 'socket.io-client' +import { useShallow } from 'zustand/react/shallow' + +import { useOAuthStore } from 'uiSrc/store' +import { BASE_RESOURCES_URL, CloudJobEvents, CloudJobName } from 'uiSrc/constants' +import { CloudJobInfo } from 'uiSrc/modules/oauth/interfaces' +import { Nullable } from 'uiSrc/interfaces' + +export const CommonAppSubscription = () => { + const { + jobId, + setJob, + } = useOAuthStore(useShallow((state) => ({ + jobId: state.job?.id || '', + setJob: state.setJob, + }))) + const socketRef = useRef>(null) + + useEffect(() => { + if (socketRef.current?.connected) { + return + } + + socketRef.current = io(`${BASE_RESOURCES_URL}`, { + path: '/socket.io', + forceNew: false, + rejectUnauthorized: false, + reconnection: true, + }) + + socketRef.current.on(CloudJobEvents.Monitor, (data: CloudJobInfo) => { + const jobName = data.name as unknown + + if ( + jobName === CloudJobName.CreateFreeDatabase + || jobName === CloudJobName.CreateFreeSubscriptionAndDatabase + || jobName === CloudJobName.ImportFreeDatabase) { + setJob(data) + } + }) + + // Catch disconnect + // socketRef.current?.on(SocketEvent.Disconnect, () => { + // unSubscribeFromAllRecommendations() + // }) + + emitCloudJobMonitor(jobId) + }, []) + + useEffect(() => { + emitCloudJobMonitor(jobId) + }, [jobId]) + + const emitCloudJobMonitor = (jobId: string) => { + if (!jobId) return + + socketRef.current?.emit( + CloudJobEvents.Monitor, + { jobId }, + ) + } + + return null +} diff --git a/src/webviews/src/modules/common-app-subscription/index.ts b/src/webviews/src/modules/common-app-subscription/index.ts new file mode 100644 index 00000000..3f55a636 --- /dev/null +++ b/src/webviews/src/modules/common-app-subscription/index.ts @@ -0,0 +1 @@ +export { CommonAppSubscription } from './CommonAppSubscription' diff --git a/src/webviews/src/modules/database-panel/DatabasePanel.tsx b/src/webviews/src/modules/database-panel/DatabasePanel.tsx index 5f6ae195..fa49b671 100644 --- a/src/webviews/src/modules/database-panel/DatabasePanel.tsx +++ b/src/webviews/src/modules/database-panel/DatabasePanel.tsx @@ -114,11 +114,6 @@ const DatabasePanel = React.memo((props: Props) => { return ( <>
-

- {!editMode ? l10n.t('Add Redis database') : l10n.t('Edit Redis database')} -

- -
{ + status: '' | CloudJobStatus +} + +export interface CloudUserFreeDbState { + loading: boolean + error: string + data: Nullable +} + +export interface CloudSuccessResult { + resourceId: string + provider?: OAuthProvider + region?: string +} + +export interface CloudImportDatabaseResources { + subscriptionId: number, + databaseId?: number + region: string + provider?: string +} + +export interface CloudAuthResponse { + status: CloudAuthStatus + message?: string + error?: object | string +} diff --git a/src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.spec.tsx b/src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.spec.tsx new file mode 100644 index 00000000..5d60204d --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils' +import * as utils from 'uiSrc/utils' +import { OAuthSocialSource } from 'uiSrc/constants' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store' +import { cleanup, fireEvent, render } from 'testSrc/helpers' +import OAuthCreateFreeDb from './OAuthCreateFreeDb' + +vi.spyOn(utils, 'sendEventTelemetry') + +beforeEach(() => { + useOAuthStore.setState({ + ...initialOAuthState, + source: 'source', + }) + cleanup() + vi.resetAllMocks() +}) + +describe('OAuthConnectFreeDb', () => { + it('should render if there is a free cloud db', () => { + const { queryByTestId } = render() + expect(queryByTestId('create-free-db-btn')).toBeInTheDocument() + }) + + it('should send telemetry after click on connect btn', async () => { + const { queryByTestId } = render() + + fireEvent.click(queryByTestId('create-free-db-btn') as HTMLButtonElement) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_FREE_DATABASE_CLICKED, + eventData: { + source: OAuthSocialSource.ListOfDatabases, + }, + }) + }) +}) diff --git a/src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.tsx b/src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.tsx new file mode 100644 index 00000000..86961091 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-create-free-db/OAuthCreateFreeDb.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import * as l10n from '@vscode/l10n' +import cx from 'classnames' +import { VscChevronRight, VscCloud } from 'react-icons/vsc' + +import { RiButton } from 'uiSrc/ui' +import { useOAuthStore } from 'uiSrc/store' +import { OAuthSocialAction, OAuthSocialSource, VscodeMessageAction } from 'uiSrc/constants' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils' +import { vscodeApi } from 'uiSrc/services' +import styles from './styles.module.scss' + +interface Props { + source: OAuthSocialSource + compressed?: boolean +} + +const OAuthCreateFreeDb = ({ source, compressed }: Props) => { + const { setSSOFlow, setSocialDialogState } = useOAuthStore((state) => ({ + setSSOFlow: state.setSSOFlow, + setSocialDialogState: state.setSocialDialogState, + })) + + const handleClick = () => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_FREE_DATABASE_CLICKED, + eventData: { source }, + }) + + if (compressed) { + vscodeApi.postMessage({ + action: VscodeMessageAction.OpenAddDatabase, + data: { + ssoFlow: OAuthSocialAction.Create, + }, + }) + + return + } + + setSSOFlow(OAuthSocialAction.Create) + setSocialDialogState(source) + } + + const description = !compressed + ? l10n.t('Includes native support for JSON, Query and Search and more.') + : l10n.t('Get free Redis Cloud database') + + return ( + <> + {!compressed &&

{l10n.t('Create free Redis Cloud database')}

} + + +
+
+ {l10n.t('Try Redis Cloud database: your ultimate Redis starting point')} +
+
+ {description} +
+
+ + {l10n.t('Create')} +
+ + ) +} + +export default OAuthCreateFreeDb diff --git a/src/webviews/src/modules/oauth/oauth-create-free-db/index.ts b/src/webviews/src/modules/oauth/oauth-create-free-db/index.ts new file mode 100644 index 00000000..65a985e8 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-create-free-db/index.ts @@ -0,0 +1,3 @@ +import OAuthCreateFreeDb from './OAuthCreateFreeDb' + +export default OAuthCreateFreeDb diff --git a/src/webviews/src/modules/oauth/oauth-create-free-db/styles.module.scss b/src/webviews/src/modules/oauth/oauth-create-free-db/styles.module.scss new file mode 100644 index 00000000..2f17f17b --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-create-free-db/styles.module.scss @@ -0,0 +1,64 @@ +.link { + @apply relative flex flex-row max-w-[650px] min-h-[80px] rounded-[4px] text-left bg-cover bg-center hover:-translate-y-[1px] justify-start pl-[10px] my-4; + + background-color: var(--vscode-editor-inactiveSelectionBackground); + + &::before { + content: ''; + @apply absolute top-0 right-0 bottom-0 left-0; + } + + .content { + @apply flex flex-col pl-3; + } + + .title { + @apply relative pt-[2px] font-medium leading-[20px] text-ellipsis overflow-hidden whitespace-nowrap text-[14px]; + } + + .description { + @apply relative pt-1 text-[12px] text-ellipsis overflow-hidden whitespace-nowrap truncate; + } + + .iconCloud { + @apply w-[30px] h-[30px]; + + fill: var(--vscode-editorLightBulb-foreground); + } + + .iconChevron { + @apply absolute right-[16px]; + } + + .compressedBtn { + @apply hidden; + } +} + +.compressed { + &.link { + @apply rounded-none max-w-full w-full min-h-[24px] items-center m-0 hover:-translate-y-0; + + &:hover { + background-color: var(--vscode-editor-inactiveSelectionBackground); + } + } + + .content { + @apply py-0 pl-2 h-[24px]; + } + + .iconCloud { + @apply w-[20px] h-[20px] mt-[2px]; + } + + .title, .iconChevron { + @apply hidden; + } + + .compressedBtn { + @apply flex absolute right-0 pl-1 pr-5 text-vscode-inputOption-activeBorder underline cursor-pointer hover:no-underline z-10; + + background-color: var(--vscode-editor-inactiveSelectionBackground); + } +} diff --git a/src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.spec.tsx b/src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.spec.tsx new file mode 100644 index 00000000..8a9851d5 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.spec.tsx @@ -0,0 +1,220 @@ +import React from 'react' +import reactElementToJSXString from 'react-element-to-jsx-string' + +import * as utils from 'uiSrc/utils/notifications/toasts' +import { CloudJobName, CloudJobStatus, CloudJobStep, CustomErrorCodes } from 'uiSrc/constants' +import { INFINITE_MESSAGES } from 'uiSrc/components' +import { OAuthStore } from 'uiSrc/store/hooks/use-oauth/interface' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store' +import { cleanup, render } from 'testSrc/helpers' +import OAuthJobs from './OAuthJobs' + +vi.spyOn(utils, 'showInfinityToast') +vi.spyOn(utils, 'showErrorInfinityToast') + +const customState: OAuthStore = { + ...initialOAuthState, + showProgress: true, + job: { + ...initialOAuthState.job, + status: '', + name: undefined, + id: '1', + }, +} + +beforeEach(() => { + useOAuthStore.setState({ ...customState }) + cleanup() + vi.resetAllMocks() +}) + +describe('OAuthJobs', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call showInfinityToast when status changed to "running"', async () => { + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Running, + }, + }) + + const { rerender } = render() + + rerender() + + const expected = reactElementToJSXString(INFINITE_MESSAGES.PENDING_CREATE_DB().Inner) + const actual = reactElementToJSXString(utils.showInfinityToast.mock.calls[0][0]) + + expect(actual).toEqual(expected) + }) + + it('should not call showInfinityToast the second time when status "running"', async () => { + const { rerender } = render() + + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Running, + }, + }) + + rerender() + + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Running, + id: '323123', + }, + }) + + rerender() + + const expected = reactElementToJSXString(INFINITE_MESSAGES.PENDING_CREATE_DB().Inner) + const actual = reactElementToJSXString(utils.showInfinityToast.mock.calls[0][0]) + + expect(actual).toEqual(expected) + }) + + it('should call loadInstances and setJob when status changed to "finished" without error', async () => { + const resourceId = '123123' + + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Finished, + step: CloudJobStep.Database, + name: CloudJobName.ImportFreeDatabase, + result: { resourceId }, + }, + }) + + render() + + const expected = reactElementToJSXString(INFINITE_MESSAGES.SUCCESS_CREATE_DB(CloudJobName.ImportFreeDatabase).Inner) + const actual = reactElementToJSXString(utils.showInfinityToast.mock.calls[0][0]) + + expect(actual).toEqual(expected) + + expect(useOAuthStore.getState().job).toEqual({ + id: '', + name: CloudJobName.CreateFreeSubscriptionAndDatabase, + status: '', + }) + }) + + it('should call loadInstances and setJob when status changed to "finished" with error', async () => { + const error = 'error' + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Failed, + error, + }, + }) + + render() + + expect(utils.showErrorInfinityToast).toHaveBeenCalledWith(error) + + expect(useOAuthStore.getState().ssoFlow).toEqual(undefined) + expect(useOAuthStore.getState().isOpenSocialDialog).toEqual(false) + }) + + it('should call showInfinityToast and removeInfinityToast when errorCode is 11_108', async () => { + const mockDatabaseId = '123' + const error = { + errorCode: CustomErrorCodes.CloudDatabaseAlreadyExistsFree, + resource: { + databaseId: mockDatabaseId, + }, + } + + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Failed, + error, + }, + }) + + render() + + const expected = reactElementToJSXString(INFINITE_MESSAGES.DATABASE_EXISTS().Inner) + const actual = reactElementToJSXString(utils.showInfinityToast.mock.calls[0][0]) + + expect(actual).toEqual(expected) + + expect(useOAuthStore.getState().ssoFlow).toEqual(undefined) + expect(useOAuthStore.getState().isOpenSocialDialog).toEqual(false) + }) + + it('should call showInfinityToast and removeInfinityToast when errorCode is 11_114', async () => { + const mockDatabaseId = '123' + const error = { + errorCode: CustomErrorCodes.CloudSubscriptionAlreadyExistsFree, + resource: { + databaseId: mockDatabaseId, + }, + } + + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Failed, + error, + }, + }) + + render() + + const expected = reactElementToJSXString(INFINITE_MESSAGES.SUBSCRIPTION_EXISTS().Inner) + const actual = reactElementToJSXString(utils.showInfinityToast.mock.calls[0][0]) + + expect(actual).toEqual(expected) + + expect(useOAuthStore.getState().ssoFlow).toEqual(undefined) + expect(useOAuthStore.getState().isOpenSocialDialog).toEqual(false) + }) + + it('should call logoutUser when statusCode is 401', async () => { + const mockDatabaseId = '123' + const error = { + statusCode: 401, + errorCode: CustomErrorCodes.CloudSubscriptionAlreadyExistsFree, + resource: { + databaseId: mockDatabaseId, + }, + } + + useOAuthStore.setState({ + ...customState, + job: { + ...customState.job, + status: CloudJobStatus.Failed, + error, + }, + }) + + render() + + const expected = reactElementToJSXString(INFINITE_MESSAGES.SUBSCRIPTION_EXISTS().Inner) + const actual = reactElementToJSXString(utils.showInfinityToast.mock.calls[0][0]) + + expect(actual).toEqual(expected) + + expect(useOAuthStore.getState().ssoFlow).toEqual(undefined) + expect(useOAuthStore.getState().isOpenSocialDialog).toEqual(false) + }) +}) diff --git a/src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.tsx b/src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.tsx new file mode 100644 index 00000000..c4d80780 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-jobs/OAuthJobs.tsx @@ -0,0 +1,151 @@ +import { useEffect } from 'react' +import { get } from 'lodash' +import { useShallow } from 'zustand/react/shallow' + +import { CloudJobStatus, CloudJobName, ApiStatusCode, StorageItem, CustomErrorCodes, CloudJobStep, VscodeMessageAction } from 'uiSrc/constants' +import { parseCustomError, TelemetryEvent, sendEventTelemetry, getApiErrorMessage } from 'uiSrc/utils' +import { showInfinityToast, removeInfinityToast, showErrorInfinityToast } from 'uiSrc/utils/notifications/toasts' +import { localStorageService, vscodeApi } from 'uiSrc/services' +import { createFreeDbJob, useOAuthStore } from 'uiSrc/store' +import { INFINITE_MESSAGES } from 'uiSrc/components' +import { CloudImportDatabaseResources } from '../interfaces' + +const OAuthJobs = () => { + const { + status, + jobName, + error, + step, + result, + showProgress, + setSSOFlow, + setJob, + setSocialDialogState, + } = useOAuthStore(useShallow((state) => ({ + status: state.job?.status, + jobName: state.job?.name, + error: state.job?.error, + step: state.job?.step, + result: state.job?.result, + showProgress: state.showProgress, + setSSOFlow: state.setSSOFlow, + setJob: state.setJob, + setSocialDialogState: state.setSocialDialogState, + }))) + + const onConnect = () => { + vscodeApi.postMessage({ + action: VscodeMessageAction.CloseAddDatabase, + }) + } + + useEffect(() => { + switch (status) { + case CloudJobStatus.Running: + if (!showProgress) return + + showInfinityToast(INFINITE_MESSAGES.PENDING_CREATE_DB(step as CloudJobStep)?.Inner) + break + + case CloudJobStatus.Finished: + + showInfinityToast(INFINITE_MESSAGES.SUCCESS_CREATE_DB(jobName, onConnect)?.Inner) + + setJob({ + id: '', + name: CloudJobName.CreateFreeSubscriptionAndDatabase, + status: '', + }) + + localStorageService.remove(StorageItem.OAuthJobId) + + vscodeApi.postMessage({ action: VscodeMessageAction.RefreshDatabases }) + break + + case CloudJobStatus.Failed: + const errorCode = get(error, 'errorCode', 0) as CustomErrorCodes + const subscriptionId = get(error, 'resource.subscriptionId', 0) + const resources = get(error, 'resource', {}) as CloudImportDatabaseResources + const statusCode = get(error, 'statusCode', 0) as number + + if (statusCode === ApiStatusCode.Unauthorized) { + // dispatch(logoutUserAction()) + } + + switch (errorCode) { + case CustomErrorCodes.CloudDatabaseAlreadyExistsFree: + showInfinityToast( + INFINITE_MESSAGES.DATABASE_EXISTS( + () => importDatabase(resources), + closeImportDatabase, + ).Inner) + break + + case CustomErrorCodes.CloudSubscriptionAlreadyExistsFree: + showInfinityToast(INFINITE_MESSAGES.SUBSCRIPTION_EXISTS( + () => createFreeDatabase(subscriptionId), + closeCreateFreeDatabase, + ).Inner) + break + + default: + const err = parseCustomError(error || '' as any) + showErrorInfinityToast(getApiErrorMessage(err)) + break + } + + setSSOFlow() + setSocialDialogState(null) + break + + default: + break + } + }, [status, error, step, result, showProgress]) + + const importDatabase = (resources: CloudImportDatabaseResources) => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_IMPORT_EXISTING_DATABASE, + }) + createFreeDbJob({ + name: CloudJobName.ImportFreeDatabase, + resources, + onSuccessAction: () => { + showInfinityToast(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)?.Inner) + }, + }) + } + + const createFreeDatabase = (subscriptionId: number) => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION, + }) + createFreeDbJob({ + name: CloudJobName.CreateFreeDatabase, + resources: { subscriptionId }, + onSuccessAction: () => { + showInfinityToast(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)?.Inner) + }, + }) + } + + const closeImportDatabase = () => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED, + }) + removeInfinityToast() + setSSOFlow() + } + + const closeCreateFreeDatabase = () => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED, + }) + removeInfinityToast() + setSSOFlow() + } + + return null +} + +export default OAuthJobs diff --git a/src/webviews/src/modules/oauth/oauth-jobs/index.ts b/src/webviews/src/modules/oauth/oauth-jobs/index.ts new file mode 100644 index 00000000..7be18312 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-jobs/index.ts @@ -0,0 +1,3 @@ +import OAuthJobs from './OAuthJobs' + +export default OAuthJobs diff --git a/src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.spec.tsx b/src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.spec.tsx new file mode 100644 index 00000000..0a43295d --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.spec.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +import { OAuthSocialAction } from 'uiSrc/constants' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store' +import { OAuthStore } from 'uiSrc/store/hooks/use-oauth/interface' +import { render, screen } from 'testSrc/helpers' +import OAuthSsoDialog from './OAuthSsoDialog' + +const customState: OAuthStore = { + ...initialOAuthState, + agreement: true, + isOpenSocialDialog: true, + source: 'source', +} + +beforeEach(() => { + useOAuthStore.setState(customState) +}) + +describe('OAuthSsoDialog', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render proper modal with ssoFlow = OAuthSocialAction.Create', () => { + useOAuthStore.setState({ + ...customState, + ssoFlow: OAuthSocialAction.Create, + }) + render() + + expect(screen.getByTestId('oauth-container-create-db')).toBeInTheDocument() + }) + + it.skip('should render proper modal with ssoFlow = OAuthSocialAction.Import', () => { + useOAuthStore.setState({ + ...customState, + ssoFlow: OAuthSocialAction.Import, + }) + render() + + expect(screen.getByTestId('oauth-container-signIn')).toBeInTheDocument() + }) + + it.skip('should render proper modal with ssoFlow = OAuthSocialAction.SignIn', () => { + useOAuthStore.setState({ + ...customState, + ssoFlow: OAuthSocialAction.SignIn, + }) + render() + + expect(screen.getByTestId('oauth-container-signIn')).toBeInTheDocument() + }) +}) diff --git a/src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.tsx b/src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.tsx new file mode 100644 index 00000000..93a5aee7 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso-dialog/OAuthSsoDialog.tsx @@ -0,0 +1,68 @@ +import React, { useCallback } from 'react' +import cx from 'classnames' +import { useShallow } from 'zustand/react/shallow' +import Popup from 'reactjs-popup' +import { VscClose } from 'react-icons/vsc' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils' +import { useOAuthStore } from 'uiSrc/store' +import { OAuthSocialAction } from 'uiSrc/constants' +import { RiButton } from 'uiSrc/ui' +import { OAuthCreateDb } from '../oauth-sso' +import styles from './styles.module.scss' + +const OAuthSsoDialog = () => { + const { + isOpenSocialDialog, + source, + ssoFlow, + setSocialDialogState, + } = useOAuthStore(useShallow((state) => ({ + isOpenSocialDialog: state.isOpenSocialDialog, + source: state.source, + ssoFlow: state.ssoFlow, + setSocialDialogState: state.setSocialDialogState, + }))) + + const handleClose = useCallback(() => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_SIGN_IN_FORM_CLOSED, + eventData: { + action: ssoFlow, + }, + }) + setSocialDialogState(null) + }, [ssoFlow]) + + if (!isOpenSocialDialog || !ssoFlow) { + return null + } + + return ( + + + + +
+ {ssoFlow === 'create' && } + {/* TODO: Signin and Import */} + {/* {ssoFlow === 'signIn' && } */} + {/* {ssoFlow === 'import' && ()} */} +
+
+ ) +} + +export default OAuthSsoDialog diff --git a/src/webviews/src/modules/oauth/oauth-sso-dialog/index.ts b/src/webviews/src/modules/oauth/oauth-sso-dialog/index.ts new file mode 100644 index 00000000..da2fff6c --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso-dialog/index.ts @@ -0,0 +1,3 @@ +import OAuthSsoDialog from './OAuthSsoDialog' + +export default OAuthSsoDialog diff --git a/src/webviews/src/modules/oauth/oauth-sso-dialog/styles.module.scss b/src/webviews/src/modules/oauth/oauth-sso-dialog/styles.module.scss new file mode 100644 index 00000000..e607068a --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso-dialog/styles.module.scss @@ -0,0 +1,17 @@ +.modal { + @apply flex overflow-auto bg-[var(--vscode-tab-inactiveBackground)] min-h-[472px]; + padding: 0 !important; + + &.createDb, &.import { + @apply max-w-[768px] min-h-[500px] #{!important}; + } +} + +:global(.oauth-sso-dialog-content) { + @apply p-0 rounded-[4px] #{!important}; + border-width: 0 !important; +} + +:global(.oauth-sso-dialog-overlay) { + background: rgba(0, 0, 0, .5); +} diff --git a/src/webviews/src/modules/oauth/oauth-sso/index.ts b/src/webviews/src/modules/oauth/oauth-sso/index.ts new file mode 100644 index 00000000..2d7583f9 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso/index.ts @@ -0,0 +1,5 @@ +import OAuthCreateDb from './oauth-create-db' + +export { + OAuthCreateDb, +} diff --git a/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.spec.tsx b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.spec.tsx new file mode 100644 index 00000000..b5d06fe4 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react' + +import { sendEventTelemetry, showInfinityToast, TelemetryEvent } from 'uiSrc/utils' +import * as utils from 'uiSrc/utils' +import { CloudJobName, CloudJobStatus, CloudJobStep, OAuthSocialAction, OAuthStrategy } from 'uiSrc/constants' +import { MOCK_OAUTH_SSO_EMAIL } from 'uiSrc/mocks/data/oauth' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store' +import { INFINITE_MESSAGES } from 'uiSrc/components' +import { act, cleanup, constants, fireEvent, render, screen } from 'testSrc/helpers' +import OAuthCreateDb from './OAuthCreateDb' + +vi.spyOn(utils, 'sendEventTelemetry') +vi.spyOn(utils, 'showInfinityToast') + +beforeEach(() => { + useOAuthStore.setState({ ...initialOAuthState, + agreement: true, + }) +}) + +beforeEach(() => { + cleanup() +}) + +describe('OAuthCreateDb', () => { + it('should render proper components ', () => { + expect(render()).toBeTruthy() + }) + + it('should render proper components if user is not logged in', () => { + render() + + expect(screen.getByTestId('oauth-advantages')).toBeInTheDocument() + expect(screen.getByTestId('oauth-container-social-buttons')).toBeInTheDocument() + expect(screen.getByTestId('oauth-agreement-checkbox')).toBeInTheDocument() + expect(screen.getByTestId('oauth-recommended-settings-checkbox')).toBeInTheDocument() + }) + + it('should call proper actions after click on sso sign button', async () => { + render() + + fireEvent.click(screen.getByTestId('sso-oauth')) + + expect(screen.getByTestId('sso-email')).toBeInTheDocument() + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED, + eventData: { + accountOption: OAuthStrategy.SSO, + action: OAuthSocialAction.Create, + cloudRecommendedSettings: 'enabled', + }, + }) + + await act(async () => { + fireEvent.change(screen.getByTestId('sso-email'), { target: { value: MOCK_OAUTH_SSO_EMAIL } }) + }) + + expect(screen.getByTestId('btn-submit')).not.toBeDisabled() + + await act(async () => { + fireEvent.click(screen.getByTestId('btn-submit')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SSO_OPTION_PROCEEDED, + eventData: { + action: OAuthSocialAction.Create, + }, + }) + }) + + it('should call proper actions after click on sign button', () => { + render() + + fireEvent.click(screen.getByTestId('google-oauth')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED, + eventData: { + accountOption: OAuthStrategy.Google, + action: OAuthSocialAction.Create, + cloudRecommendedSettings: 'enabled', + }, + }) + }) + + it('should render proper components if user is logged in', () => { + render() + + expect(screen.getByTestId('oauth-advantages')).toBeInTheDocument() + // expect(screen.getByTestId('oauth-create-db')).toBeInTheDocument() + expect(screen.getByTestId('oauth-recommended-settings-checkbox')).toBeInTheDocument() + + // expect(screen.queryByTestId('oauth-agreement-checkbox')).not.toBeInTheDocument() + // expect(screen.queryByTestId('oauth-container-social-buttons')).not.toBeInTheDocument() + }) + + it('should call proper actions after click create', async () => { + const name = CloudJobName.CreateFreeSubscriptionAndDatabase + useOAuthStore.setState({ ...initialOAuthState, + agreement: true, + user: { + ...initialOAuthState.user, + data: {}, + }, + }) + + render() + + await act(() => { + fireEvent.click(screen.getByTestId('oauth-create-db')) + }) + + expect(showInfinityToast).toBeCalledWith(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials).Inner) + + expect(useOAuthStore.getState().isOpenSocialDialog).toEqual(false) + expect(useOAuthStore.getState().job).toEqual( + { id: constants.USER_JOBS_DATA.id, name, status: CloudJobStatus.Running }, + ) + }) + + it.skip('should call proper actions after click create without recommened settings', async () => { + const name = CloudJobName.CreateFreeSubscriptionAndDatabase + useOAuthStore.setState({ ...initialOAuthState, + agreement: true, + source: 'source', + user: { + ...initialOAuthState.user, + data: {}, + }, + }) + render() + + await act(async () => { + fireEvent.click(screen.getByTestId('oauth-recommended-settings-checkbox')) + }) + + fireEvent.click(screen.getByTestId('oauth-create-db')) + + expect(showInfinityToast).toBeCalledWith(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials).Inner) + expect(useOAuthStore.getState().isOpenSocialDialog).toEqual(false) + expect(useOAuthStore.getState().job).toEqual( + { id: constants.USER_JOBS_DATA.id, name, status: CloudJobStatus.Running }, + ) + }) +}) diff --git a/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.tsx b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.tsx new file mode 100644 index 00000000..d9f96ae6 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/OAuthCreateDb.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import * as l10n from '@vscode/l10n' +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' + +import { CloudJobName, CloudJobStep, OAuthSocialAction, OAuthSocialSource } from 'uiSrc/constants' +import { sendEventTelemetry, showInfinityToast, TelemetryEvent } from 'uiSrc/utils' +import { Nullable } from 'uiSrc/interfaces' +import { createFreeDbJob, useOAuthStore } from 'uiSrc/store' +import { Spacer } from 'uiSrc/ui' +import { INFINITE_MESSAGES } from 'uiSrc/components' + +import { OAuthForm } from '../../shared/oauth-form' +import OAuthAgreement from '../../shared/oauth-agreement/OAuthAgreement' +import { OAuthAdvantages, OAuthRecommendedSettings } from '../../shared' +import styles from './styles.module.scss' + +export interface Props { + source?: Nullable +} + +const OAuthCreateDb = (props: Props) => { + const { source } = props + const { + data, + setSSOFlow, + showOAuthProgress, + setSocialDialogState, + } = useOAuthStore(useShallow((state) => ({ + data: state.user.data, + setSSOFlow: state.setSSOFlow, + showOAuthProgress: state.showOAuthProgress, + setSocialDialogState: state.setSocialDialogState, + }))) + + const [isRecommended, setIsRecommended] = useState(true) + + const handleSocialButtonClick = (accountOption: string) => { + const cloudRecommendedSettings = isRecommended ? 'enabled' : 'disabled' + + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED, + eventData: { + accountOption, + action: OAuthSocialAction.Create, + cloudRecommendedSettings, + source, + }, + }) + } + + const handleChangeRecommendedSettings = (value: boolean) => { + setIsRecommended(value) + } + + const handleClickCreate = () => { + setSSOFlow(OAuthSocialAction.Create) + showOAuthProgress(true) + showInfinityToast(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)?.Inner) + setSocialDialogState(null) + + if (isRecommended) { + createFreeDbJob({ + name: CloudJobName.CreateFreeSubscriptionAndDatabase, + resources: { + isRecommendedSettings: isRecommended, + }, + onFailAction: () => { + setSSOFlow(undefined) + }, + }) + } + } + + return ( +
+
+ +
+
+ {!data ? ( + + {(form: React.ReactNode) => ( + <> +
{l10n.t('Get started with')}
+

{l10n.t('Free Cloud database')}

+ {form} +
+ + +
+ + )} +
+ ) : ( + <> +
{l10n.t('Get your')}
+

{l10n.t('Free Cloud database')}

+ +
+ {l10n.t('The database will be created automatically and can be changed from Redis Cloud.')} +
+ + + + + {l10n.t('Create')} + + + )} +
+
+ ) +} + +export default OAuthCreateDb diff --git a/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/index.ts b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/index.ts new file mode 100644 index 00000000..58e9e7e8 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/index.ts @@ -0,0 +1,3 @@ +import OAuthCreateDb from './OAuthCreateDb' + +export default OAuthCreateDb diff --git a/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/styles.module.scss b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/styles.module.scss new file mode 100644 index 00000000..ddb52845 --- /dev/null +++ b/src/webviews/src/modules/oauth/oauth-sso/oauth-create-db/styles.module.scss @@ -0,0 +1,23 @@ +.container { + @apply flex flex-row flex-grow; + + .advantagesContainer { + @apply flex min-w-[320px] px-6 bg-[var(--vscode-editor-background)]; + } + + .socialContainer { + @apply flex flex-col items-center min-w-[446px] max-w-[446px] pt-[108px] px-[60px] pb-[60px]; + + .subTitle { + @apply text-[16px]; + } + + .title { + @apply text-[28px] font-bold; + } + } + + .socialButtons { + @apply my-[40px] mb-[60px]; + } +} diff --git a/src/webviews/src/modules/oauth/shared/index.ts b/src/webviews/src/modules/oauth/shared/index.ts new file mode 100644 index 00000000..7d8a4a30 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/index.ts @@ -0,0 +1,11 @@ +import OAuthAdvantages from './oauth-advantages' +import OAuthAgreement from './oauth-agreement' +import OAuthRecommendedSettings from './oauth-recommended-settings' +import OAuthSocialButtons from './oauth-social-buttons' + +export { + OAuthAdvantages, + OAuthAgreement, + OAuthRecommendedSettings, + OAuthSocialButtons, +} diff --git a/src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.spec.tsx b/src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.spec.tsx new file mode 100644 index 00000000..276b3905 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { render } from 'testSrc/helpers' + +import OAuthAdvantages from './OAuthAdvantages' + +describe('OAuthAdvantages', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.tsx b/src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.tsx new file mode 100644 index 00000000..8785ff65 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-advantages/OAuthAdvantages.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import * as l10n from '@vscode/l10n' +import { VscCheck } from 'react-icons/vsc' +import RedisLogo from 'uiSrc/assets/logo.svg?react' +import { OAUTH_ADVANTAGES_ITEMS } from './constants' + +import styles from './styles.module.scss' + +const OAuthAdvantages = () => ( +
+ +
+

{l10n.t('Cloud')}

+
+
+ {OAUTH_ADVANTAGES_ITEMS.map(({ title }) => ( +
+ +
{title}
+
+ ))} +
+
+) + +export default OAuthAdvantages diff --git a/src/webviews/src/modules/oauth/shared/oauth-advantages/constants.ts b/src/webviews/src/modules/oauth/shared/oauth-advantages/constants.ts new file mode 100644 index 00000000..aab92385 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-advantages/constants.ts @@ -0,0 +1,16 @@ +import * as l10n from '@vscode/l10n' + +export const OAUTH_ADVANTAGES_ITEMS = [ + { + title: l10n.t('Structured querying and full-text search'), + }, + { + title: l10n.t('Native support for JSON'), + }, + { + title: l10n.t('Scalable and fully managed'), + }, + { + title: l10n.t('Free database to get started immediately'), + }, +] diff --git a/src/webviews/src/modules/oauth/shared/oauth-advantages/index.ts b/src/webviews/src/modules/oauth/shared/oauth-advantages/index.ts new file mode 100644 index 00000000..73218e9f --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-advantages/index.ts @@ -0,0 +1,3 @@ +import OAuthAdvantages from './OAuthAdvantages' + +export default OAuthAdvantages diff --git a/src/webviews/src/modules/oauth/shared/oauth-advantages/styles.module.scss b/src/webviews/src/modules/oauth/shared/oauth-advantages/styles.module.scss new file mode 100644 index 00000000..3a6869a7 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-advantages/styles.module.scss @@ -0,0 +1,27 @@ +.container { + @apply flex flex-col items-center justify-center flex-grow; +} + +.advantages { + @apply flex flex-col items-stretch justify-between bg-[var(--cloudSsoAdvantagesBgColor)]; +} + +.logo { + @apply w-[120px] h-auto mb-[12px]; +} + +.title { + @apply text-[18px] font-normal text-[var(--euiTextSubduedColor)] mb-[40px]; +} + +.advantageTitle { + @apply text-[12px] leading-normal; +} + +.advantage { + @apply flex items-center mt-[12px]; +} + +.advantageIcon { + @apply min-w-[14px] mr-[6px]; +} diff --git a/src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.spec.tsx b/src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.spec.tsx new file mode 100644 index 00000000..da669d63 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.spec.tsx @@ -0,0 +1,41 @@ +import React from 'react' + +import { localStorageService } from 'uiSrc/services' +import { StorageItem } from 'uiSrc/constants' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store' +import { + cleanup, + fireEvent, + render, + screen, +} from 'testSrc/helpers' +import OAuthAgreement from './OAuthAgreement' + +beforeEach(() => { + cleanup() + useOAuthStore.setState({ ...initialOAuthState, + agreement: true, + }) +}) + +vi.spyOn(localStorageService, 'set') + +describe('OAuthAgreement', () => { + it('should render', () => { + expect(render()).toBeTruthy() + expect(screen.getByTestId('oauth-agreement-checkbox')).toBeChecked() + }) + + it('should call setAgreement and set value in local storage', () => { + localStorageService.set = vi.fn() + + render() + + fireEvent.click(screen.getByTestId('oauth-agreement-checkbox')) + + expect(localStorageService.set).toBeCalledWith( + StorageItem.OAuthAgreement, + false, + ) + }) +}) diff --git a/src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.tsx b/src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.tsx new file mode 100644 index 00000000..73ec23a1 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-agreement/OAuthAgreement.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import cx from 'classnames' +import { useShallow } from 'zustand/react/shallow' +import * as l10n from '@vscode/l10n' + +import { localStorageService, vscodeApi } from 'uiSrc/services' +import { StorageItem, VscodeMessageAction } from 'uiSrc/constants' +import { Checkbox, CheckboxChangeEvent } from 'uiSrc/ui' +import { enableUserAnalyticsAction } from 'uiSrc/store/hooks/use-app-info-store/useAppInfoStore' +import { useOAuthStore } from 'uiSrc/store' +import styles from './styles.module.scss' + +export interface Props { + size?: 's' | 'm' +} + +const links = { + cloudTerms: 'https://redis.io/legal/cloud-tos/?utm_source=redisinsight&utm_medium=main&utm_campaign=main', + policy: 'https://redis.io/legal/privacy-policy/?utm_source=redisinsight&utm_medium=main&utm_campaign=main', +} + +const OAuthAgreement = (props: Props) => { + const { size = 'm' } = props + + const { agreement, setAgreement } = useOAuthStore(useShallow((state) => ({ + agreement: state.agreement, + setAgreement: state.setAgreement, + }))) + + const handleCheck = (e: CheckboxChangeEvent) => { + if (e.target.checked) { + enableUserAnalyticsAction() + } + setAgreement(e.target.checked) + localStorageService.set(StorageItem.OAuthAgreement, e.target.checked) + } + + const handleLinkClick = (e: React.MouseEvent, href = '') => { + e.preventDefault() + vscodeApi.postMessage({ + action: VscodeMessageAction.OpenExternalUrl, + data: href, + }) + } + + return ( +
+ + +
+ ) +} + +export default OAuthAgreement diff --git a/src/webviews/src/modules/oauth/shared/oauth-agreement/index.ts b/src/webviews/src/modules/oauth/shared/oauth-agreement/index.ts new file mode 100644 index 00000000..c65c1460 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-agreement/index.ts @@ -0,0 +1,3 @@ +import OAuthAgreement from './OAuthAgreement' + +export default OAuthAgreement diff --git a/src/webviews/src/modules/oauth/shared/oauth-agreement/styles.module.scss b/src/webviews/src/modules/oauth/shared/oauth-agreement/styles.module.scss new file mode 100644 index 00000000..65172ba7 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-agreement/styles.module.scss @@ -0,0 +1,24 @@ +.wrapper { + .list { + @apply list-disc pl-[32px] mt-[4px]; + + .listItem { + @apply pb-1 text-[12px] leading-[15px] text-[var(--vscode-foreground)]; + } + } + + &.small { + .list { + @apply list-disc pl-[28px] mt-[2px]; + + .listItem { + font-size: 10px; + line-height: 15px; + } + } + } +} + +.link { + @apply cursor-pointer hover:underline; +} diff --git a/src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.spec.tsx b/src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.spec.tsx new file mode 100644 index 00000000..915aa768 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.spec.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils' +import { OAuthSocialAction, OAuthStrategy } from 'uiSrc/constants' +import { MOCK_OAUTH_SSO_EMAIL } from 'uiSrc/mocks/data/oauth' +import * as utils from 'uiSrc/utils' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store/hooks/use-oauth/useOAuthStore' +import { render, cleanup, fireEvent, screen, act, waitFor } from 'testSrc/helpers' +import { OAuthForm } from './OAuthForm' + +vi.spyOn(utils, 'sendEventTelemetry') + +beforeEach(() => { + useOAuthStore.setState({ ...initialOAuthState, + agreement: true, + source: 'source', + }) +}) +beforeEach(() => { + cleanup() + vi.resetAllMocks() +}) + +describe('OAuthForm', () => { + it('should render', () => { + expect(render( + {}} + > + {(children) => (<>{children})}), + ).toBeTruthy() + }) + + it('should call proper actions after click on google', () => { + const onClick = vi.fn() + render({(children) => (<>{children})}) + + fireEvent.click(screen.getByTestId('google-oauth')) + + expect(onClick).toBeCalledWith(OAuthStrategy.Google) + }) + + it('should call proper actions after click on sso', async () => { + const onClick = vi.fn() + render({(children) => (<>{children})}) + + fireEvent.click(screen.getByTestId('sso-oauth')) + + expect(screen.getByTestId('sso-email')).toBeInTheDocument() + + await act(async () => { + fireEvent.change(screen.getByTestId('sso-email'), { target: { value: MOCK_OAUTH_SSO_EMAIL } }) + }) + + expect(screen.getByTestId('btn-submit')).not.toBeDisabled() + + await act(async () => { + fireEvent.click(screen.getByTestId('btn-submit')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SSO_OPTION_PROCEEDED, + eventData: { action: OAuthSocialAction.Create }, + }) + + expect(onClick).toBeCalledWith(OAuthStrategy.SSO) + }) + + it('should go back to main oauth form by clicking to back button', async () => { + const onClick = vi.fn() + render({(children) => (<>{children})}) + + fireEvent.click(screen.getByTestId('sso-oauth')) + + expect(screen.getByTestId('sso-email')).toBeInTheDocument() + expect(screen.getByTestId('btn-back')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('btn-back')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SSO_OPTION_CANCELED, + eventData: { action: OAuthSocialAction.Create }, + }) + + expect(screen.getByTestId('sso-oauth')).toBeInTheDocument() + }) + + it('should disable submit button id incorrect email provided', async () => { + const onClick = vi.fn() + render({(children) => (<>{children})}) + + fireEvent.click(screen.getByTestId('sso-oauth')) + + expect(screen.getByTestId('sso-email')).toBeInTheDocument() + + await act(async () => { + fireEvent.input(screen.getByTestId('sso-email'), { target: { value: 'bad-email' } }) + }) + + const submitBtn = screen.getByTestId('btn-submit') as HTMLButtonElement + expect(submitBtn?.disabled).toBe(true) + + await act(async () => { + fireEvent.mouseOver(submitBtn) + }) + + await act(async () => { + fireEvent.click(submitBtn) + }) + }) +}) diff --git a/src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.tsx b/src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.tsx new file mode 100644 index 00000000..d0bed5fd --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-form/OAuthForm.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils' +import { OAuthSocialAction, OAuthStrategy, VscodeMessageAction } from 'uiSrc/constants' +import { enableUserAnalyticsAction } from 'uiSrc/store/hooks/use-app-info-store/useAppInfoStore' +import { vscodeApi } from 'uiSrc/services' +import OAuthSsoForm from './components/oauth-sso-form' +import OAuthSocialButtons from '../oauth-social-buttons' +import { Props as OAuthSocialButtonsProps } from '../oauth-social-buttons/OAuthSocialButtons' + +export interface Props extends OAuthSocialButtonsProps { + action: OAuthSocialAction + children: ( + form: React.ReactNode, + ) => JSX.Element +} + +export const OAuthForm = ({ + children, + action, + onClick, + ...rest +}: Props) => { + const [authStrategy, setAuthStrategy] = useState('') + const [disabled, setDisabled] = useState(false) + + const initOAuthProcess = (strategy: OAuthStrategy, action: string) => { + // TODO: signIn + // dispatch(signIn()) + + vscodeApi.postMessage({ + action: VscodeMessageAction.CloudOAuth, + data: { action, strategy }, + }) + } + + const onSocialButtonClick = (authStrategy: OAuthStrategy) => { + setDisabled(true) + setTimeout(() => { setDisabled(false) }, 3_000) + enableUserAnalyticsAction() + setAuthStrategy(authStrategy) + onClick?.(authStrategy) + + switch (authStrategy) { + case OAuthStrategy.Google: + case OAuthStrategy.GitHub: + initOAuthProcess(authStrategy, action) + break + case OAuthStrategy.SSO: + // ignore. sso email form will be shown + break + default: + break + } + } + + const onSsoBackButtonClick = () => { + setAuthStrategy('') + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_SIGN_IN_SSO_OPTION_CANCELED, + eventData: { + action, + }, + }) + } + + const onSsoLoginButtonClick = () => { + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_SIGN_IN_SSO_OPTION_PROCEEDED, + eventData: { + action, + }, + }) + initOAuthProcess(OAuthStrategy.SSO, action) + } + + if (authStrategy === OAuthStrategy.SSO) { + return ( + + ) + } + + return ( + children( + , + ) + ) +} diff --git a/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/OAuthSsoForm.tsx b/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/OAuthSsoForm.tsx new file mode 100644 index 00000000..9488bb61 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/OAuthSsoForm.tsx @@ -0,0 +1,112 @@ +import { isEmpty } from 'lodash' +import React, { ChangeEvent, useState } from 'react' +import { FormikErrors, useFormik } from 'formik' +import * as l10n from '@vscode/l10n' +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' +import { VscInfo } from 'react-icons/vsc' + +import { validateEmail, validateField } from 'uiSrc/utils' +import { InputText, Spacer, Tooltip } from 'uiSrc/ui' +import styles from './styles.module.scss' + +export interface Props { + onBack: () => void, + onSubmit: (values: { email: string }) => any, +} + +interface Values { + email: string; +} + +const OAuthSsoForm = ({ + onBack, + onSubmit, +}: Props) => { + const [validationErrors, setValidationErrors] = useState>({ email: '' }) + + const validate = (values: Values) => { + const errs: FormikErrors = {} + + if (!values?.email || !validateEmail(values.email)) { + errs.email = l10n.t('Invalid email') + } + + setValidationErrors(errs) + + return errs + } + + const formik = useFormik({ + initialValues: { + email: '', + }, + validate, + onSubmit, + }) + + const submitIsDisabled = () => !isEmpty(validationErrors) + + const SubmitButton = ({ + text, + disabled, + }: { disabled: boolean, text: string }) => ( + +

{l10n.t('Email must be in the format')}

+

{l10n.t('email@example.com without spaces')}

+ + ) : null} + > + onSubmit(formik.values)} + disabled={disabled} + data-testid="btn-submit" + > + {disabled && } + {text} + +
+ ) + + return ( +
+

{l10n.t('Single Sign-On')}

+
+
+ {l10n.t('Email')} + ) => { + formik.setFieldValue(e.target.name, validateField(e.target.value.trim())) + }} + /> +
+ +
+ + {l10n.t('Back')} + + +
+ +
+ ) +} + +export default OAuthSsoForm diff --git a/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/index.ts b/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/index.ts new file mode 100644 index 00000000..c09438d8 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/index.ts @@ -0,0 +1,3 @@ +import OAuthSsoForm from './OAuthSsoForm' + +export default OAuthSsoForm diff --git a/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/styles.module.scss b/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/styles.module.scss new file mode 100644 index 00000000..430b2824 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-form/components/oauth-sso-form/styles.module.scss @@ -0,0 +1,11 @@ +.container { + @apply mb-[190px] pt-[60px] w-full text-left; + + .title { + @apply pb-[20px] text-[16px] #{!important}; + } + + .formRaw { + @apply p-0 #{!important}; + } +} diff --git a/src/webviews/src/modules/oauth/shared/oauth-form/index.ts b/src/webviews/src/modules/oauth/shared/oauth-form/index.ts new file mode 100644 index 00000000..0c4bb910 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-form/index.ts @@ -0,0 +1 @@ +export { OAuthForm } from './OAuthForm' diff --git a/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.spec.tsx b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.spec.tsx new file mode 100644 index 00000000..f4c43c8a --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { render, screen, fireEvent } from 'testSrc/helpers' + +import OAuthRecommendedSettings from './OAuthRecommendedSettings' + +describe('OAuthRecommendedSettings', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it.skip('should call onChange after change value', () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('oauth-recommended-settings-checkbox')) + + expect(onChange).toBeCalledWith(false) + }) + + it('should show feature dependent items when feature flag is on', async () => { + render() + expect(screen.queryByTestId('oauth-recommended-settings-checkbox')).toBeInTheDocument() + }) +}) diff --git a/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.tsx b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.tsx new file mode 100644 index 00000000..4caaf022 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/OAuthRecommendedSettings.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { VscInfo } from 'react-icons/vsc' +import * as l10n from '@vscode/l10n' + +import { Checkbox, Tooltip } from 'uiSrc/ui' +import styles from './styles.module.scss' + +export interface Props { + value?: boolean + onChange: (value: boolean) => void +} + +const OAuthRecommendedSettings = (props: Props) => { + const { value, onChange } = props + + return ( + // TODO: feature flag for sso + // +
+ onChange(e.target.checked)} + data-testid="oauth-recommended-settings-checkbox" + /> + + {l10n.t('The database will be automatically created using a pre-selected provider and region.')} +
+ {l10n.t('You can change it by signing in to Redis Cloud.')} + + )} + > +
+
+
+ //
+ ) +} + +export default OAuthRecommendedSettings diff --git a/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/index.ts b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/index.ts new file mode 100644 index 00000000..a6db35dc --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/index.ts @@ -0,0 +1,3 @@ +import OAuthRecommendedSettings from './OAuthRecommendedSettings' + +export default OAuthRecommendedSettings diff --git a/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/styles.module.scss b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/styles.module.scss new file mode 100644 index 00000000..0eab974a --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-recommended-settings/styles.module.scss @@ -0,0 +1,7 @@ +.recommendedSettings { + @apply mb-2 flex items-center; + + .recommendedSettingsToolTip { + @apply inline-flex ml-[4px] mb-[4px]; + } +} diff --git a/src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.spec.tsx b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.spec.tsx new file mode 100644 index 00000000..e4068dfc --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { OAuthStrategy } from 'uiSrc/constants' +import { initialOAuthState, useOAuthStore } from 'uiSrc/store/hooks/use-oauth/useOAuthStore' +import { render, fireEvent, screen, waitForStack } from 'testSrc/helpers' +import OAuthSocialButtons from './OAuthSocialButtons' + +beforeEach(() => { + useOAuthStore.setState({ ...initialOAuthState, + agreement: true, + }) +}) + +describe('OAuthSocialButtons', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper actions after click on google', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByTestId('google-oauth')) + + expect(onClick).toBeCalledWith(OAuthStrategy.Google) + }) + + it('should call proper actions after click on github', async () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByTestId('github-oauth')) + + await waitForStack() + + expect(onClick).toBeCalledWith(OAuthStrategy.GitHub) + }) + + it('should call proper actions after click on sso', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByTestId('sso-oauth')) + + expect(onClick).toBeCalledWith(OAuthStrategy.SSO) + }) +}) diff --git a/src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.tsx b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.tsx new file mode 100644 index 00000000..34213cee --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/OAuthSocialButtons.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import cx from 'classnames' +import { useShallow } from 'zustand/react/shallow' + +import GoogleIcon from 'uiSrc/assets/oauth/google.svg?react' +import GithubIcon from 'uiSrc/assets/oauth/github.svg?react' +import SsoIcon from 'uiSrc/assets/oauth/sso.svg?react' +import { OAuthStrategy } from 'uiSrc/constants' +import { useOAuthStore } from 'uiSrc/store' +import { RiButton, Tooltip } from 'uiSrc/ui' +import styles from './styles.module.scss' + +export interface Props { + onClick: (authStrategy: OAuthStrategy) => void + className?: string + inline?: boolean + disabled?: boolean +} + +const OAuthSocialButtons = (props: Props) => { + const { onClick, className, inline, disabled } = props + + const { agreement } = useOAuthStore(useShallow((state) => ({ + agreement: state.agreement, + }))) + + const socialLinks = [ + { + text: 'Google', + className: styles.googleButton, + icon: GoogleIcon, + label: 'google-oauth', + strategy: OAuthStrategy.Google, + }, + { + text: 'Github', + className: styles.githubButton, + icon: GithubIcon, + label: 'github-oauth', + strategy: OAuthStrategy.GitHub, + }, + { + text: 'SSO', + className: styles.ssoButton, + icon: SsoIcon, + label: 'sso-oauth', + strategy: OAuthStrategy.SSO, + }, + ] + + return ( +
+ {socialLinks.map(({ strategy, text, icon: Icon, label, className = '' }) => ( + + <> + { + onClick(strategy) + }} + data-testid={label} + aria-labelledby={label} + > + +
{text}
+
+ +
+ ))} +
+ ) +} + +export default OAuthSocialButtons diff --git a/src/webviews/src/modules/oauth/shared/oauth-social-buttons/index.ts b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/index.ts new file mode 100644 index 00000000..49ae3e13 --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/index.ts @@ -0,0 +1,3 @@ +import OAuthSocialButtons from './OAuthSocialButtons' + +export default OAuthSocialButtons diff --git a/src/webviews/src/modules/oauth/shared/oauth-social-buttons/styles.module.scss b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/styles.module.scss new file mode 100644 index 00000000..4b8ebfbe --- /dev/null +++ b/src/webviews/src/modules/oauth/shared/oauth-social-buttons/styles.module.scss @@ -0,0 +1,34 @@ +.container { + @apply flex items-center; + + .button { + @apply flex-col h-auto mx-[20px]; + padding: 0 ; + transition: transform 0.3s ease; + + &:hover, + &:focus { + background: none; + transform: translateY(-1px); + } + + svg { + @apply h-[34px] w-[34px]; + margin: 0 ; + } + + &:global(.euiButtonEmpty) { + .label { + @apply text-[var(--vscode-foreground)]; + } + } + + &.githubButton { + svg { + path { + fill: var(--vscode-foreground); + } + } + } + } +} diff --git a/src/webviews/src/pages/AddDatabasePage/AddDatabasePage.tsx b/src/webviews/src/pages/AddDatabasePage/AddDatabasePage.tsx index df2b4fc5..3ade45c6 100644 --- a/src/webviews/src/pages/AddDatabasePage/AddDatabasePage.tsx +++ b/src/webviews/src/pages/AddDatabasePage/AddDatabasePage.tsx @@ -1,5 +1,10 @@ +import { VSCodeDivider } from '@vscode/webview-ui-toolkit/react' import React, { FC, useEffect } from 'react' -import { DatabasePanel } from 'uiSrc/modules' +import * as l10n from '@vscode/l10n' + +import { OAuthSocialSource } from 'uiSrc/constants' +import { CommonAppSubscription, DatabasePanel } from 'uiSrc/modules' +import { OAuthCreateFreeDb, OAuthSsoDialog, OAuthJobs } from 'uiSrc/modules/oauth' import { fetchCerts } from 'uiSrc/store' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/utils' @@ -13,6 +18,15 @@ export const AddDatabasePage: FC = () => { return (
+

+ {l10n.t('Add Redis database')} +

+ + + + + +
) diff --git a/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx b/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx index 5a2f5527..f723dea8 100644 --- a/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx +++ b/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx @@ -1,4 +1,6 @@ import React, { FC, useEffect } from 'react' +import * as l10n from '@vscode/l10n' +import { VSCodeDivider } from '@vscode/webview-ui-toolkit/react' import { DatabasePanel } from 'uiSrc/modules' import { fetchCerts, fetchEditedDatabase, useDatabasesStore } from 'uiSrc/store' @@ -14,6 +16,10 @@ export const EditDatabasePage: FC = () => { return (
+

+ {l10n.t('Edit Redis database')} +

+
) diff --git a/src/webviews/src/pages/MainPage/MainPage.tsx b/src/webviews/src/pages/MainPage/MainPage.tsx index 359fc51b..7785e05b 100644 --- a/src/webviews/src/pages/MainPage/MainPage.tsx +++ b/src/webviews/src/pages/MainPage/MainPage.tsx @@ -1,8 +1,10 @@ import React, { FC } from 'react' import { Outlet } from 'react-router-dom' +import { GlobalToasts } from 'uiSrc/components' export const MainPage: FC = () => (
+
) diff --git a/src/webviews/src/pages/SidebarPage/SidebarPage.tsx b/src/webviews/src/pages/SidebarPage/SidebarPage.tsx index ce0a9742..4a338e7d 100644 --- a/src/webviews/src/pages/SidebarPage/SidebarPage.tsx +++ b/src/webviews/src/pages/SidebarPage/SidebarPage.tsx @@ -4,6 +4,8 @@ import { NoDatabases } from 'uiSrc/components' import { DatabaseWrapper } from 'uiSrc/modules' import { useDatabasesStore } from 'uiSrc/store' import { useAppInfoStore } from 'uiSrc/store/hooks/use-app-info-store/useAppInfoStore' +import { OAuthCreateFreeDb } from 'uiSrc/modules/oauth' +import { OAuthSocialSource } from 'uiSrc/constants' export const SidebarPage: FC = () => { const databases = useDatabasesStore((state) => state.data) @@ -18,6 +20,7 @@ export const SidebarPage: FC = () => { return (
+ {databases.map((database) => ( ))} diff --git a/src/webviews/src/store/hooks/use-app-info-store/useAppInfoStore.ts b/src/webviews/src/store/hooks/use-app-info-store/useAppInfoStore.ts index 089a3b12..3883a4fb 100644 --- a/src/webviews/src/store/hooks/use-app-info-store/useAppInfoStore.ts +++ b/src/webviews/src/store/hooks/use-app-info-store/useAppInfoStore.ts @@ -120,3 +120,13 @@ export function updateUserConfigSettingsAction( } }) } + +export function enableUserAnalyticsAction() { + useAppInfoStore.setState(async (state) => { + const agreements = state?.config?.agreements + + if (agreements && !agreements.analytics) { + updateUserConfigSettingsAction({ agreements: { ...agreements, analytics: true } }) + } + }) +} diff --git a/src/webviews/src/store/hooks/use-oauth/interface.ts b/src/webviews/src/store/hooks/use-oauth/interface.ts new file mode 100644 index 00000000..d4c15b5d --- /dev/null +++ b/src/webviews/src/store/hooks/use-oauth/interface.ts @@ -0,0 +1,98 @@ +import { CloudSubscriptionType, OAuthSocialAction, OAuthSocialSource } from 'uiSrc/constants' +import { Maybe, Nullable } from 'uiSrc/interfaces' +import { CloudJobInfoState } from 'uiSrc/modules/oauth/interfaces' +import { Database } from 'uiSrc/store' + +export interface Certificate { + id: string + name: string +} + +export interface OAuthStore { + ssoFlow: Maybe + source: Nullable + job: Nullable + isOpenSocialDialog: boolean + agreement: boolean + showProgress: boolean + isRecommendedSettings: boolean + user: { + initialLoading: boolean + loading: boolean + data: Nullable + freeDb: CloudUserFreeDbState + } +} + +export interface OauthActions { + setSSOFlow: (ssoFlow?: OAuthSocialAction) => void + setOAuthCloudSource: (source: Nullable) => void + showOAuthProgress: (showProgress: boolean) => void + setSocialDialogState: (source: Nullable) => void + setJob: (job: CloudJobInfoState) => void + setAgreement: (agreement: boolean) => void + + getUserInfo: () => void + getUserInfoSuccess: (data: CloudUser) => void + getUserInfoFinal: () => void +} + +export interface CloudUser { + id?: number + name?: string + currentAccountId?: number + capiKey?: CloudCapiKey + accounts?: CloudUserAccount[] +} + +export interface CloudCapiKey { + id: string + userId: string + name: string + cloudAccountId: number + cloudUserId: number + capiKey: string + capiSecret: string + valid?: boolean + createdAt?: Date + lastUsed?: Date +} + +export interface CloudUserAccount { + id: number + name: string + capiKey?: string // api_access_key + capiSecret?: string +} + +export interface CloudUserFreeDbState { + loading: boolean + data: Nullable +} + +export interface CloudSubscriptionPlanResponse extends CloudSubscriptionPlan { + details: CloudSubscriptionRegion +} + +export interface CloudSubscriptionPlan { + id: number + regionId: number + type: CloudSubscriptionType + name: string + provider: string + region?: string + price?: number +} + +export interface CloudSubscriptionRegion { + id: string + regionId: number + name: string + displayOrder: number + region?: string + provider?: string + cloud?: string + countryName?: string + cityName?: string + flag?: string +} diff --git a/src/webviews/src/store/hooks/use-oauth/useOAuthStore.spec.ts b/src/webviews/src/store/hooks/use-oauth/useOAuthStore.spec.ts new file mode 100644 index 00000000..fe66ef1a --- /dev/null +++ b/src/webviews/src/store/hooks/use-oauth/useOAuthStore.spec.ts @@ -0,0 +1,72 @@ +import * as modules from 'uiSrc/modules' +import { CloudJobName, CloudJobStatus } from 'uiSrc/constants' +import { constants } from 'testSrc/helpers' +import { waitForStack } from 'testSrc/helpers/testUtils' +import { + useOAuthStore, + initialOAuthState, + fetchUserInfo, + createFreeDbJob, +} from './useOAuthStore' + +beforeEach(() => { + useOAuthStore.setState(initialOAuthState) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useOAuthStore', () => { + it('getUserInfo', () => { + // Arrange + const { getUserInfo } = useOAuthStore.getState() + // Act + getUserInfo() + // Assert + expect(useOAuthStore.getState().user.loading).toEqual(true) + }) + + it('getUserInfoFinal', () => { + // Arrange + const initialState = { ...initialOAuthState, loading: true } // Custom initial state + useOAuthStore.setState((state) => ({ ...state, ...initialState })) + + const { getUserInfoFinal } = useOAuthStore.getState() + // Act + getUserInfoFinal() + // Assert + expect(useOAuthStore.getState().user.loading).toEqual(false) + }) + it('getUserInfoSuccess', () => { + // Arrange + const initialState = { ...initialOAuthState, loading: true } // Custom initial state + useOAuthStore.setState((state) => ({ ...state, ...initialState })) + + const { getUserInfoSuccess } = useOAuthStore.getState() + // Act + getUserInfoSuccess(constants.USER_DATA) + // Assert + expect(useOAuthStore.getState().user.data).toEqual(constants.USER_DATA) + }) +}) + +describe('async', () => { + it('fetchUserInfo', async () => { + fetchUserInfo() + await waitForStack() + + expect(useOAuthStore.getState().user.data).toEqual(constants.USER_DATA) + expect(useOAuthStore.getState().user.loading).toEqual(false) + }) + + it('createFreeDbJob', async () => { + const name = CloudJobName.CreateFreeSubscriptionAndDatabase + createFreeDbJob({ name }) + await waitForStack() + + expect(useOAuthStore.getState().job).toEqual( + { id: constants.USER_JOBS_DATA.id, name, status: CloudJobStatus.Running }, + ) + }) +}) diff --git a/src/webviews/src/store/hooks/use-oauth/useOAuthStore.ts b/src/webviews/src/store/hooks/use-oauth/useOAuthStore.ts new file mode 100644 index 00000000..77760299 --- /dev/null +++ b/src/webviews/src/store/hooks/use-oauth/useOAuthStore.ts @@ -0,0 +1,145 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import { immer } from 'zustand/middleware/immer' +import { apiService, localStorageService } from 'uiSrc/services' +import { ApiEndpoints, CloudJobName, CloudJobStatus, OAuthSocialAction, StorageItem } from 'uiSrc/constants' +import { CloudJobInfo } from 'uiSrc/modules/oauth/interfaces' +import { getApiErrorMessage, getCloudSsoUtmParams, isStatusSuccessful, showErrorMessage } from 'uiSrc/utils' +import { EnhancedAxiosError } from 'uiSrc/interfaces' +import { CloudUser, OauthActions, OAuthStore } from './interface' + +export const initialOAuthState: OAuthStore = { + job: { + id: localStorageService.get(StorageItem.OAuthJobId) ?? '', + name: undefined, + status: '', + }, + source: null, + ssoFlow: undefined, + isOpenSocialDialog: false, + + agreement: localStorageService.get(StorageItem.OAuthAgreement) ?? false, + showProgress: true, + isRecommendedSettings: true, + user: { + initialLoading: true, + loading: false, + data: null, + freeDb: { + loading: false, + data: null, + }, + }, +} + +export const useOAuthStore = create()( + immer(devtools((set, get) => ({ + ...initialOAuthState, + // actions + setSSOFlow: (ssoFlow) => set({ ssoFlow }), + setJob: (job) => set({ job }), + setAgreement: (agreement) => set({ agreement }), + setOAuthCloudSource: (source) => set({ source }), + showOAuthProgress: (showProgress) => set({ showProgress }), + setSocialDialogState: (source) => set({ + source: source || get().source, + isOpenSocialDialog: !!source, + }), + + getUserInfo: () => set((state) => { + state.user.loading = true + }), + getUserInfoSuccess: (data) => set((state) => { + state.user.data = data + }), + getUserInfoFinal: () => set((state) => { + state.user.loading = false + }), + }))), +) + +// Asynchronous thunk action +export function createFreeDbJob({ + name, + resources = {}, + onSuccessAction, + onFailAction, +}: { + name: CloudJobName, + resources?: { + planId?: number, + databaseId?: number, + subscriptionId?: number, + region?: string, + provider?: string, + isRecommendedSettings?: boolean + } + onSuccessAction?: () => void, + onFailAction?: () => void +}) { + useOAuthStore.setState(async (state) => { + try { + const { data, status } = await apiService.post( + ApiEndpoints.CLOUD_ME_JOBS, + { + name, + runMode: 'async', + data: resources, + }, + ) + + if (isStatusSuccessful(status)) { + localStorageService.set(StorageItem.OAuthJobId, data.id) + state.setJob( + { id: data.id, name, status: CloudJobStatus.Running }, + ) + onSuccessAction?.() + } + } catch (error) { + showErrorMessage(getApiErrorMessage(error as EnhancedAxiosError)) + state.setOAuthCloudSource(null) + + onFailAction?.() + } + }) +} + +// Asynchronous thunk action +export function fetchUserInfo(onSuccessAction?: (isSelectAccount: boolean) => void, onFailAction?: () => void) { + useOAuthStore.setState(async (state) => { + state.getUserInfo() + + try { + const { data, status } = await apiService.get( + ApiEndpoints.CLOUD_ME, + { + params: getCloudSsoUtmParams(state.source), + }, + ) + + if (isStatusSuccessful(status)) { + const isSignInFlow = state.ssoFlow === OAuthSocialAction.SignIn + const isSelectAccount = !isSignInFlow && (data?.accounts?.length ?? 0) > 1 + + if (isSelectAccount) { + throw new Error('Multi account is not supported yet') + // TODO: select account for SSO + // state.setSelectAccountDialogState(true) + // state.removeInfiniteNotification(InfiniteMessagesIds.oAuthProgress) + } + + state.getUserInfoSuccess(data) + state.setSocialDialogState(null) + + onSuccessAction?.(isSelectAccount) + } + } catch (error) { + showErrorMessage(getApiErrorMessage(error as EnhancedAxiosError)) + state.setOAuthCloudSource(null) + + onFailAction?.() + } finally { + state.getUserInfoFinal() + } + }) +} diff --git a/src/webviews/src/store/index.ts b/src/webviews/src/store/index.ts index 0d641d8f..28aa0fb5 100644 --- a/src/webviews/src/store/index.ts +++ b/src/webviews/src/store/index.ts @@ -5,6 +5,7 @@ export * from './hooks/use-selected-key-store/useSelectedKeyStore' export * from './hooks/use-certificates-store/useCertificatesStore' export * from './hooks/use-databases-store/useDatabasesStore' export * from './hooks/use-context/useContext' +export * from './hooks/use-oauth/useOAuthStore' export type * from './hooks/use-databases-store/interface' export type * from './zustandTypes' diff --git a/src/webviews/src/styles/components/_popup.scss b/src/webviews/src/styles/components/_popup.scss index 4ebb2844..8bbcb3f4 100644 --- a/src/webviews/src/styles/components/_popup.scss +++ b/src/webviews/src/styles/components/_popup.scss @@ -1,5 +1,5 @@ .popup-content { - @apply bg-vscode-tab-activeBackground break-words p-4 border-vscode-focusBorder; + @apply bg-vscode-tab-activeBackground break-words p-4 border-vscode-focusBorder overflow-auto max-h-full; max-width: calc(100% - 50px); border: 1px solid var(--vscode-focusBorder); diff --git a/src/webviews/src/types/index.d.ts b/src/webviews/src/types/index.d.ts index 2e294b7b..537ecdea 100644 --- a/src/webviews/src/types/index.d.ts +++ b/src/webviews/src/types/index.d.ts @@ -1,6 +1,7 @@ import { Environment } from 'monaco-editor/esm/vs/editor/editor.api' import { IVSCodeApi, Nullable, RedisString } from 'uiSrc/interfaces' import { Database } from 'uiSrc/store' +import { OAuthSocialAction } from 'uiSrc/constants' import { AppInfoStore } from 'uiSrc/store/hooks/use-app-info-store/interface' declare global { @@ -19,4 +20,5 @@ interface IRI { } appPort?: string appInfo: Nullable> + ssoFlow?: OAuthSocialAction } diff --git a/src/webviews/src/ui/spinner/Spinner.tsx b/src/webviews/src/ui/spinner/Spinner.tsx index 9ffae1a0..f127ea0d 100644 --- a/src/webviews/src/ui/spinner/Spinner.tsx +++ b/src/webviews/src/ui/spinner/Spinner.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react' import cx from 'classnames' import { BarLoader, BeatLoader, ClipLoader } from 'react-spinners' +import { LengthType } from 'react-spinners/helpers/props' import styles from './styles.module.scss' @@ -8,9 +9,10 @@ export interface Props { type?: 'bar' | 'beat' | 'clip' loading?: boolean className?: string + size?: LengthType } -export const Spinner: FC = ({ type, loading, className }) => { +export const Spinner: FC = ({ type, loading, className, size }) => { switch (type) { case 'bar': return ( @@ -37,6 +39,7 @@ export const Spinner: FC = ({ type, loading, className }) => { diff --git a/src/webviews/src/utils/core/apiResponses.ts b/src/webviews/src/utils/core/apiResponses.ts index d6cc79b3..8893b52c 100644 --- a/src/webviews/src/utils/core/apiResponses.ts +++ b/src/webviews/src/utils/core/apiResponses.ts @@ -1,7 +1,8 @@ import { AxiosError } from 'axios' import { first, get, isArray } from 'lodash' import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/constants' -import { Nullable } from 'uiSrc/interfaces' +import { EnhancedAxiosError, Nullable } from 'uiSrc/interfaces' +import { parseCustomError } from './errors' export function getApiErrorMessage(error: Nullable): string { const errorMessage = get(error, 'response.data.message', '') @@ -18,3 +19,18 @@ export function getApiErrorMessage(error: Nullable): string { export function getApiErrorName(error: AxiosError): string { return get(error, 'response.data.name', 'Error') ?? '' } + +export const getAxiosError = (error: EnhancedAxiosError): AxiosError => { + if (error?.response?.data.errorCode) { + return parseCustomError(error.response.data) + } + return error +} + +export const createAxiosError = (options: ErrorOptions): AxiosError => ({ + response: { + data: options, + }, +}) as AxiosError + +export const getApiErrorCode = (error: AxiosError) => error?.response?.status diff --git a/src/webviews/src/utils/core/errors.tsx b/src/webviews/src/utils/core/errors.tsx index d0bc4b69..c3e3f044 100644 --- a/src/webviews/src/utils/core/errors.tsx +++ b/src/webviews/src/utils/core/errors.tsx @@ -1,6 +1,10 @@ import React from 'react' -import { pickBy, identity } from 'lodash' -import { validationErrors } from 'uiSrc/constants' +import { pickBy, identity, set, isString, isEmpty } from 'lodash' +import { AxiosError } from 'axios' +import { CustomErrorCodes, DEFAULT_ERROR_MESSAGE, EXTERNAL_LINKS, UTM_CAMPAINGS, UTM_MEDIUMS, validationErrors } from 'uiSrc/constants' +import { CustomError } from 'uiSrc/interfaces' +import { Spacer } from 'uiSrc/ui' +import { getUtmExternalLink } from './links' const maxErrorsCount = 5 @@ -28,3 +32,180 @@ export const getRequiredFieldsText = (errorsInit: { [key: string]: any }) => {
) } + +export const parseCustomError = (err: CustomError | string = DEFAULT_ERROR_MESSAGE): AxiosError => { + const error = { + response: { + status: 500, + data: { }, + }, + } + + if (isString(err)) { + return set(error, 'response.data.message', err) as AxiosError + } + + let title: string = 'Error' + let message: React.ReactElement | string = '' + const additionalInfo: Record = {} + + switch (err?.errorCode) { + case CustomErrorCodes.CloudOauthGithubEmailPermission: + title = 'Github Email Permission' + message = ( + <> + Unable to get an email from the GitHub account. Make sure that it is available. +
+ + ) + break + case CustomErrorCodes.CloudOauthMisconfiguration: + title = 'Misconfiguration' + message = ( + <> + Authorization server encountered a misconfiguration error and was unable to complete your request. + + Try again later. + + If the issue persists, report the issue. + + ) + break + case CustomErrorCodes.CloudOauthUnknownAuthorizationRequest: + title = 'Error' + message = ( + <> + Unknown authorization request. + + If the issue persists, report the issue. + + ) + break + case CustomErrorCodes.CloudOauthUnexpectedError: + title = 'Error' + message = ( + <> + An unexpected error occurred. + + If the issue persists, report the issue. + + ) + break + case CustomErrorCodes.CloudOauthSsoUnsupportedEmail: + title = 'Invalid email' + message = ( + <> + Invalid email. + + ) + break + case CustomErrorCodes.CloudApiBadRequest: + title = 'Bad request' + message = ( + <> + Your request resulted in an error. + + Try again later. + + If the issue persists, report the issue. + + ) + break + + case CustomErrorCodes.CloudApiForbidden: + title = 'Access denied' + message = ( + <> + You do not have permission to access Redis Cloud. + + ) + break + + case CustomErrorCodes.CloudApiInternalServerError: + title = 'Server error' + message = ( + <> + Try restarting Redis Insight. + + If the issue persists, report the issue. + + ) + break + + case CustomErrorCodes.CloudApiNotFound: + title = 'Resource was not found' + message = ( + <> + Resource requested could not be found. + + Try again later. + + If the issue persists, report the issue. + + ) + break + + case CustomErrorCodes.CloudCapiUnauthorized: + case CustomErrorCodes.CloudApiUnauthorized: + case CustomErrorCodes.QueryAiUnauthorized: + title = 'Session expired' + message = ( + <> + Sign in again to continue working with Redis Cloud. + + If the issue persists, report the issue. + + ) + break + + case CustomErrorCodes.CloudCapiKeyUnauthorized: + title = 'Invalid API key' + message = ( + <> + Your Redis Cloud authorization failed. + + Remove the invalid API key from Redis Insight and try again. + + Open the Settings page to manage Redis Cloud API keys. + + ) + additionalInfo.resourceId = err.resourceId + additionalInfo.errorCode = err.errorCode + break + + case CustomErrorCodes.CloudDatabaseAlreadyExistsFree: + title = 'Database already exists' + message = ( + <> + You already have a free Redis Cloud database running. + + Check out your + + {' Cloud console '} + + for connection details. + + ) + break + + default: + title = 'Error' + message = err?.message || DEFAULT_ERROR_MESSAGE + break + } + + const parsedError: any = { title, message } + + if (!isEmpty(additionalInfo)) { + parsedError.additionalInfo = additionalInfo + } + + return set(error, 'response.data', parsedError) as AxiosError +} diff --git a/src/webviews/src/utils/core/index.ts b/src/webviews/src/utils/core/index.ts index 26853951..3afb0b28 100644 --- a/src/webviews/src/utils/core/index.ts +++ b/src/webviews/src/utils/core/index.ts @@ -6,8 +6,14 @@ export { isStatusServerError, isStatusNotFoundError, } from './statuses' -export { getApiErrorMessage, getApiErrorName } from './apiResponses' -export { getRequiredFieldsText } from './errors' +export { + getApiErrorMessage, + getApiErrorName, + getAxiosError, + createAxiosError, + getApiErrorCode, +} from './apiResponses' +export { getRequiredFieldsText, parseCustomError } from './errors' export { getUtmExternalLink } from './links' export { IS_ABSOLUTE_PATH } from './regex' export type { UTMParams } from './links' diff --git a/src/webviews/src/utils/index.ts b/src/webviews/src/utils/index.ts index 614aa295..7e8f643f 100644 --- a/src/webviews/src/utils/index.ts +++ b/src/webviews/src/utils/index.ts @@ -12,3 +12,5 @@ export * from './validators/validations' export * from './table/column' export * from './events' export * from './decompressors/decompressors' +export * from './oauth/cloudSsoUtm' +export * from './notifications' diff --git a/src/webviews/src/utils/notifications/index.ts b/src/webviews/src/utils/notifications/index.ts new file mode 100644 index 00000000..31a35fce --- /dev/null +++ b/src/webviews/src/utils/notifications/index.ts @@ -0,0 +1 @@ +export * from './toasts' diff --git a/src/webviews/src/utils/notifications/toasts.tsx b/src/webviews/src/utils/notifications/toasts.tsx new file mode 100644 index 00000000..d9995135 --- /dev/null +++ b/src/webviews/src/utils/notifications/toasts.tsx @@ -0,0 +1,39 @@ +import React, { ReactNode } from 'react' +import { toast, ToastOptions } from 'react-toastify' + +export const INFINITY_TOAST_ID = 'infinity_toast_id' + +const notify = (content: ReactNode = '', options?: ToastOptions) => { + toast(
{content}
, { + autoClose: false, + toastId: INFINITY_TOAST_ID, + ...options, + type: options?.type || 'default', + }) +} + +const update = (content: ReactNode = '', options?: ToastOptions) => { + toast.update(INFINITY_TOAST_ID, { + render:
{content}
, + autoClose: false, + ...options, + type: options?.type || 'default', + }) +} + +export const showInfinityToast = (content: ReactNode = '', options?: ToastOptions) => { + if (!toast.isActive(INFINITY_TOAST_ID)) { + notify(content, options) + return + } + + update(content, options) +} + +export const showErrorInfinityToast = (content: ReactNode = '', options?: ToastOptions) => { + showInfinityToast(content, { ...options, type: 'error' }) +} + +export const removeInfinityToast = () => { + toast.dismiss(INFINITY_TOAST_ID) +} diff --git a/src/webviews/src/utils/oauth/cloudSsoUtm.tsx b/src/webviews/src/utils/oauth/cloudSsoUtm.tsx new file mode 100644 index 00000000..d4b726ae --- /dev/null +++ b/src/webviews/src/utils/oauth/cloudSsoUtm.tsx @@ -0,0 +1,43 @@ +import { CloudSsoUtmCampaign, OAuthSocialSource } from 'uiSrc/constants' + +// Map oauth social source to utm campaign parameter +export const getCloudSsoUtmCampaign = (source?: string | null): CloudSsoUtmCampaign => { + switch (source) { + case OAuthSocialSource.ListOfDatabases: + return CloudSsoUtmCampaign.ListOfDatabases + case OAuthSocialSource.BrowserSearch: + return CloudSsoUtmCampaign.BrowserSearch + case OAuthSocialSource.RediSearch: + case OAuthSocialSource.RedisJSON: + case OAuthSocialSource.RedisTimeSeries: + case OAuthSocialSource.RedisGraph: + case OAuthSocialSource.RedisBloom: + return CloudSsoUtmCampaign.Workbench + case OAuthSocialSource.BrowserContentMenu: + return CloudSsoUtmCampaign.BrowserOverview + case OAuthSocialSource.BrowserFiltering: + return CloudSsoUtmCampaign.BrowserFilter + case OAuthSocialSource.WelcomeScreen: + return CloudSsoUtmCampaign.WelcomeScreen + case OAuthSocialSource.Tutorials: + return CloudSsoUtmCampaign.Tutorial + case OAuthSocialSource.Autodiscovery: + case OAuthSocialSource.DiscoveryForm: + return CloudSsoUtmCampaign.AutoDiscovery + case OAuthSocialSource.AiChat: + return CloudSsoUtmCampaign.Copilot + case OAuthSocialSource.UserProfile: + return CloudSsoUtmCampaign.UserProfile + case OAuthSocialSource.SettingsPage: + return CloudSsoUtmCampaign.Settings + default: + return CloudSsoUtmCampaign.Unknown + } +} + +// Create search query utm parameters +export const getCloudSsoUtmParams = (source?: string | null): URLSearchParams => new URLSearchParams([ + ['source', 'redisinsight'], + ['medium', 'sso'], // todo: distinguish between electron and web? + ['campaign', getCloudSsoUtmCampaign(source)], +]) diff --git a/src/webviews/src/utils/telemetry/events.ts b/src/webviews/src/utils/telemetry/events.ts index a92e0921..8a8c8615 100644 --- a/src/webviews/src/utils/telemetry/events.ts +++ b/src/webviews/src/utils/telemetry/events.ts @@ -250,7 +250,10 @@ export enum TelemetryEvent { CLOUD_FREE_DATABASE_CLICKED = 'CLOUD_FREE_DATABASE_CLICKED', CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED = 'CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED', + CLOUD_SIGN_IN_SSO_OPTION_PROCEEDED = 'CLOUD_SIGN_IN_SSO_OPTION_PROCEEDED', + CLOUD_SIGN_IN_SSO_OPTION_CANCELED = 'CLOUD_SIGN_IN_SSO_OPTION_CANCELED', CLOUD_SIGN_IN_FORM_CLOSED = 'CLOUD_SIGN_IN_FORM_CLOSED', + CLOUD_SIGN_IN_CLICKED = 'CLOUD_SIGN_IN_CLICKED', CLOUD_SIGN_IN_SUCCEEDED = 'CLOUD_SIGN_IN_SUCCEEDED', CLOUD_SIGN_IN_FAILED = 'CLOUD_SIGN_IN_FAILED', CLOUD_SIGN_IN_ACCOUNT_SELECTED = 'CLOUD_SIGN_IN_ACCOUNT_SELECTED', @@ -258,12 +261,18 @@ export enum TelemetryEvent { 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_IMPORT_DATABASES_SUBMITTED = 'CLOUD_IMPORT_DATABASES_SUBMITTED', CLOUD_API_KEY_REMOVED = 'CLOUD_API_KEY_REMOVED', CLOUD_LINK_CLICKED = 'CLOUD_LINK_CLICKED', CLOUD_IMPORT_EXISTING_DATABASE = 'CLOUD_IMPORT_EXISTING_DATABASE', CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED = 'CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED', CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION = 'CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION', CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED = 'CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED', + CLOUD_PROFILE_OPENED = 'CLOUD_PROFILE_OPENED', + CLOUD_ACCOUNT_SWITCHED = 'CLOUD_ACCOUNT_SWITCHED', + CLOUD_CONSOLE_CLICKED = 'CLOUD_CONSOLE_CLICKED', + CLOUD_SIGN_OUT_CLICKED = 'CLOUD_SIGN_OUT_CLICKED', + CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED = 'CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED', 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/src/webviews/test/handlers/index.ts b/src/webviews/test/handlers/index.ts index 088366f8..4915f6a4 100644 --- a/src/webviews/test/handlers/index.ts +++ b/src/webviews/test/handlers/index.ts @@ -1,10 +1,12 @@ import browser from './browser' import database from './database' import app from './app' +import oauth from './oauth' // @ts-ignore export const handlers: any[] = [].concat( app, browser, database, + oauth, ) diff --git a/src/webviews/test/handlers/oauth/index.ts b/src/webviews/test/handlers/oauth/index.ts new file mode 100644 index 00000000..6131ad68 --- /dev/null +++ b/src/webviews/test/handlers/oauth/index.ts @@ -0,0 +1,8 @@ +import { RequestHandler } from 'msw' + +import oauth from './oauthHandlers' + +const handlers: RequestHandler[] = [].concat( + oauth, +) +export default handlers diff --git a/src/webviews/test/handlers/oauth/oauthHandlers.ts b/src/webviews/test/handlers/oauth/oauthHandlers.ts new file mode 100644 index 00000000..e7784de2 --- /dev/null +++ b/src/webviews/test/handlers/oauth/oauthHandlers.ts @@ -0,0 +1,17 @@ +import { http, HttpResponse, RequestHandler } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { constants, getMWSUrl } from 'testSrc/helpers' + +const handlers: RequestHandler[] = [ + // fetchCerts + http.get( + getMWSUrl(`${ApiEndpoints.CLOUD_ME}`), + () => HttpResponse.json(constants.USER_DATA), + ), + http.post( + getMWSUrl(`${ApiEndpoints.CLOUD_ME_JOBS}`), + () => HttpResponse.json(constants.USER_JOBS_DATA), + ), +] + +export default handlers diff --git a/src/webviews/test/helpers/constants.ts b/src/webviews/test/helpers/constants.ts index 47c4ea42..88cbf575 100644 --- a/src/webviews/test/helpers/constants.ts +++ b/src/webviews/test/helpers/constants.ts @@ -251,6 +251,9 @@ export const constants = { }, COMMAND: 'keys *', + + USER_DATA: { id: 123123 }, + USER_JOBS_DATA: { id: '123123' }, } const KEY_INFO: KeyInfo = { diff --git a/yarn.lock b/yarn.lock index 7132b78c..18e76e4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -494,6 +494,11 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@base2/pretty-print-object@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.2.tgz#e30192222fd13e3c1e97040163d6628a95f70844" + integrity sha512-rBha0UDfV7EmBRjWrGG7Cpwxg8WomPlo0q+R2so47ZFf9wy4YKJzLuHcVa0UGFjdcLZj/4F/1FNC46GIQhe7sA== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1341,6 +1346,11 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== + "@stablelib/snappy@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@stablelib/snappy/-/snappy-1.0.3.tgz#404d6484cd122d7673d8471f3c18240d29ec1a8b" @@ -2990,6 +3000,11 @@ clsx@^1.0.4: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + cockatiel@^3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/cockatiel/-/cockatiel-3.1.3.tgz#bb1774a498a17e739dd994d56610dc6538b02858" @@ -3318,6 +3333,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@~4.3.1, debug@~4.3.2: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" @@ -3617,6 +3639,22 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" +engine.io-client@~6.6.1: + version "6.6.2" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.2.tgz#e0a09e1c90effe5d6264da1c56d7281998f1e50b" + integrity sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + xmlhttprequest-ssl "~2.1.1" + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + enhanced-resolve@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz#4d6e689b3725f86090927ccc86cd9f1635b89e2e" @@ -5232,6 +5270,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-plain-object@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -6100,7 +6143,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: +ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -7052,6 +7095,14 @@ react-dom@^18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-element-to-jsx-string@^17.0.0: + version "17.0.0" + resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-17.0.0.tgz#8619b35e10011cce85aaf54cc1336b5e8b3aef4d" + integrity sha512-R0wqLsBwvEfIHhhtVxZUgUlwohnkxY2A11UhUFKgA1we/kurVF/v/2RRET5coyY+v/czQFTPI+YvDZ3lxhyEwQ== + dependencies: + "@base2/pretty-print-object" "1.0.2" + is-plain-object "5.0.0" + react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" @@ -7172,6 +7223,13 @@ react-spinners@^0.13.8: resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc" integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA== +react-toastify@^11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-11.0.3.tgz#1684de60baf745e761d3c608bb29581657e2fe01" + integrity sha512-cbPtHJPfc0sGqVwozBwaTrTu1ogB9+BLLjd4dDXd863qYLj7DGrQ2sg5RAChjFUB4yc3w8iXOtWcJqPK/6xqRQ== + dependencies: + clsx "^2.1.1" + react-transition-group@^4.3.0: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" @@ -7716,6 +7774,31 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" +socket.io-client@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.1.tgz#1941eca135a5490b94281d0323fe2a35f6f291cb" + integrity sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.6.1" + socket.io-parser "~4.2.4" + +socket.io-mock@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/socket.io-mock/-/socket.io-mock-1.3.2.tgz#3f6f56f9bc2a2852783bd8aae85159def5cd1942" + integrity sha512-p4MQBue3NAR8bXIHynRJxK/C+J3I3NpnnpgjptgLFSWv4u9Bdkubf2t0GCmyLmUTi03up0Cx/hQwzQfOpD187g== + dependencies: + component-emitter "^1.3.0" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -8866,7 +8949,7 @@ ws@^8.13.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== -ws@^8.17.1: +ws@^8.17.1, ws@~8.17.1: version "8.17.1" resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== @@ -8894,6 +8977,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xmlhttprequest-ssl@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23" + integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" From b7003f7c0759b710a99550ccd3bfa6fe6c2c55df Mon Sep 17 00:00:00 2001 From: Egor Zalenski Date: Tue, 28 Jan 2025 19:29:06 +0100 Subject: [PATCH 2/3] #RI-6509 - Create a free db vscode --- .github/workflows/pipeline-build-windows.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pipeline-build-windows.yml b/.github/workflows/pipeline-build-windows.yml index 0e4a7d4b..e709da2c 100644 --- a/.github/workflows/pipeline-build-windows.yml +++ b/.github/workflows/pipeline-build-windows.yml @@ -26,17 +26,15 @@ jobs: - name: Configure Environment Variables run: | - { - echo "RI_SEGMENT_WRITE_KEY=${{ env.RI_SEGMENT_WRITE_KEY }}" - echo "RI_CLOUD_IDP_AUTHORIZE_URL=${{ env.RI_CLOUD_IDP_AUTHORIZE_URL }}" - echo "RI_CLOUD_IDP_TOKEN_URL=${{ env.RI_CLOUD_IDP_TOKEN_URL }}" - echo "RI_CLOUD_IDP_REVOKE_TOKEN_URL=${{ env.RI_CLOUD_IDP_REVOKE_TOKEN_URL }}" - echo "RI_CLOUD_IDP_REDIRECT_URI=${{ env.RI_CLOUD_IDP_REDIRECT_URI }}" - echo "RI_CLOUD_IDP_ISSUER=${{ env.RI_CLOUD_IDP_ISSUER }}" - echo "RI_CLOUD_IDP_CLIENT_ID=${{ env.RI_CLOUD_IDP_CLIENT_ID }}" - echo "RI_CLOUD_IDP_GOOGLE_ID=${{ env.RI_CLOUD_IDP_GOOGLE_ID }}" - echo "RI_CLOUD_IDP_GH_ID=${{ env.RI_CLOUD_IDP_GH_ID }}" - } >> "${{ env.envFile }}" + echo "RI_SEGMENT_WRITE_KEY=${{ env.RI_SEGMENT_WRITE_KEY }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_AUTHORIZE_URL=${{ env.RI_CLOUD_IDP_AUTHORIZE_URL }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_TOKEN_URL=${{ env.RI_CLOUD_IDP_TOKEN_URL }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_REVOKE_TOKEN_URL=${{ env.RI_CLOUD_IDP_REVOKE_TOKEN_URL }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_REDIRECT_URI=${{ env.RI_CLOUD_IDP_REDIRECT_URI }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_ISSUER=${{ env.RI_CLOUD_IDP_ISSUER }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_CLIENT_ID=${{ env.RI_CLOUD_IDP_CLIENT_ID }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_GOOGLE_ID=${{ env.RI_CLOUD_IDP_GOOGLE_ID }}" >> ${{ env.envFile }} + echo "RI_CLOUD_IDP_GH_ID=${{ env.RI_CLOUD_IDP_GH_ID }}" >> ${{ env.envFile }} - name: Build windows package (production) if: inputs.environment == 'production' From 049bc606c35c98fb9d5c7886cf91e2b7b0da3fc9 Mon Sep 17 00:00:00 2001 From: Egor Zalenski Date: Wed, 29 Jan 2025 15:14:22 +0100 Subject: [PATCH 3/3] #RI-6509 - Create a free db vscode --- src/constants.ts | 5 + src/lib/cloud_oauth_callback/callback.html | 46 ++++++++ src/lib/cloud_oauth_callback/favicon.png | Bin 0 -> 429 bytes .../fonts/Graphik-Medium.woff2 | Bin 0 -> 35489 bytes .../fonts/Graphik-Regular.woff2 | Bin 0 -> 36525 bytes src/lib/cloud_oauth_callback/index.js | 27 +++++ src/lib/cloud_oauth_callback/styles.css | 102 ++++++++++++++++++ src/utils/handleUri.ts | 12 +-- src/utils/wrapErrorSensitiveData.ts | 1 - .../ManualConnection.spec.tsx | 1 - 10 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 src/lib/cloud_oauth_callback/callback.html create mode 100644 src/lib/cloud_oauth_callback/favicon.png create mode 100644 src/lib/cloud_oauth_callback/fonts/Graphik-Medium.woff2 create mode 100644 src/lib/cloud_oauth_callback/fonts/Graphik-Regular.woff2 create mode 100644 src/lib/cloud_oauth_callback/index.js create mode 100644 src/lib/cloud_oauth_callback/styles.css diff --git a/src/constants.ts b/src/constants.ts index a3b9a107..e8d76264 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -18,3 +18,8 @@ export const EXTERNAL_LINKS = { export const DEFAULT_USER_ID = '1' export const DEFAULT_SESSION_ID = '1' + +export enum UrlHandlingActions { + OAuthCallback = '/cloud/oauth/callback', + Connect = '/databases/connect', +} diff --git a/src/lib/cloud_oauth_callback/callback.html b/src/lib/cloud_oauth_callback/callback.html new file mode 100644 index 00000000..6329f060 --- /dev/null +++ b/src/lib/cloud_oauth_callback/callback.html @@ -0,0 +1,46 @@ + + + + + Redis for Visual Studio Code + + + + +
+
+
+ +
+
+

Thank you

+

+ To complete the authentication, click "Open Visual Studio Code" +

+
+ Click open Visual Studio Code below, if you don't see the dialog. +
+ +
+ In case you are not redirected, check that your package supports deep linking and try again. +
+
+ If the issue persists, manually add your database from Redis Cloud. +
+
+
+
+ + + diff --git a/src/lib/cloud_oauth_callback/favicon.png b/src/lib/cloud_oauth_callback/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..0610a302ba657ec9a5e9b4ed7e62a3deefe4cf89 GIT binary patch literal 429 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyQ2{<7u0WboN*RQtL5M>}MLtnC8WJ)pXc- z-=5TDz|$rwV3vDHI5jf7_WyMg7VoW7KOXzf^rGgz51%vteb4`wou)3k{o^G^ezA>h z%aQEG;`a{bu?b3@I9PDaD#>b_QqGmw-l*ispL8r%7u7BIl3-0aykA^Le4)o5Q7=dR z8)yHxd{}dK=>w6wM=sSoDANm_e2{y2YDDm~6QNfm7MlgF5NfLGYV+z~I(Fou*rAk` zGmXM!{MDt#b!}~6DUU>ii+kAaLuj^NK$o@JUUc?@zBFCTC SqLTm&1O`u6KbLh*2~7awf~H&m literal 0 HcmV?d00001 diff --git a/src/lib/cloud_oauth_callback/fonts/Graphik-Medium.woff2 b/src/lib/cloud_oauth_callback/fonts/Graphik-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6214da69c859f3f0761da079b3e67b7b3d183025 GIT binary patch literal 35489 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 literal 0 HcmV?d00001 diff --git a/src/lib/cloud_oauth_callback/fonts/Graphik-Regular.woff2 b/src/lib/cloud_oauth_callback/fonts/Graphik-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b1fd3d4b69a7626788ad793bb46c91bd992139c4 GIT binary patch literal 36525 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 { + try { + const currentUrl = new URL(window.location.href) + const redirectUrl = protocol + extension + callbackUrl + currentUrl.search + const isOpened = window.location.hash === '#success' + + if (forceOpen || !isOpened) { + window.location.href = redirectUrl.toString() + } + + window.location.hash = '#success' + } catch (_e) { + // + } +} + +// handlers +openAppButton.addEventListener('click', () => openApp(true)) + +openApp() diff --git a/src/lib/cloud_oauth_callback/styles.css b/src/lib/cloud_oauth_callback/styles.css new file mode 100644 index 00000000..7b8c87a3 --- /dev/null +++ b/src/lib/cloud_oauth_callback/styles.css @@ -0,0 +1,102 @@ +@font-face { + font-family: 'Graphik'; + src: url('fonts/Graphik-Regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'Graphik'; + font-weight: 500; + src: url('fonts/Graphik-Medium.woff2') format('woff2'); +} + +body { + margin: 0; + font: normal normal normal 11px/14px 'Graphik', sans-serif; +} + +.container { + height: 100vh; + width: 100vw; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.content { + width: 540px; + min-height: 357px; +} + +.header { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + height: 150px; + border-radius: 16px 16px 0 0; + background-image: url('data:image/png;base64,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'); +} + +.logo { + width: 170px; +} + +.section { + display: flex; + text-align: center; + align-items: center; + flex-direction: column; + line-height: normal; + color: #000; + padding: 50px 24px; + border-radius: 0 0 16px 16px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); +} + +.title { + font-size: 28px; + font-weight: 500; + margin: 0; +} + +.subTitle { + font-size: 16px; + padding-top: 20px; + font-weight: 400; + margin: 0; +} + +.text { + font-size: 12px; + font-weight: 400; + margin-top: 20px; + color: #527298; +} + +.link { + color: #3163D8; +} + +.button { + display: flex; + padding: 6px 12px; + margin-top: 16px; + min-height: 38px; + + justify-content: center; + align-items: center; + + border-radius: 4px; + background: #465282; + box-shadow: 4px 4px 20px 0px rgba(0, 0, 0, 0.20); + + color: #fff; + font-size: 14px; + font-weight: 400; + + border: 0; + outline: 0; + + cursor: pointer; +} diff --git a/src/utils/handleUri.ts b/src/utils/handleUri.ts index 077d4dc4..d421b41b 100644 --- a/src/utils/handleUri.ts +++ b/src/utils/handleUri.ts @@ -1,20 +1,20 @@ import * as vscode from 'vscode' import { cloudOauthCallback } from '../lib/auth/auth.handler' +import { UrlHandlingActions } from '../constants' export async function registerUriHandler() { vscode.window.registerUriHandler({ handleUri }) } async function handleUri(uri: vscode.Uri) { - const query = Object.fromEntries(new URLSearchParams(uri.query)) - // const query = parse(uri.query) - - if (uri.path.startsWith('/cloud/oauth/callback')) { + if (uri.path.startsWith(UrlHandlingActions.OAuthCallback)) { + const query = Object.fromEntries(new URLSearchParams(uri.query)) await cloudOauthCallback(query) return } - if (uri.path.startsWith('/databases/connect')) { - // sidebarProvider.view?.webview.postMessage({ action: 'oauthConnect' }) + // TODO: add database from oath callback url + if (uri.path.startsWith(UrlHandlingActions.Connect)) { + // sidebarProvider.view?.webview.postMessage({ action: 'SetSearchUrl', data: uri.query }) } } diff --git a/src/utils/wrapErrorSensitiveData.ts b/src/utils/wrapErrorSensitiveData.ts index b613cea8..cbf5e78a 100644 --- a/src/utils/wrapErrorSensitiveData.ts +++ b/src/utils/wrapErrorSensitiveData.ts @@ -1,4 +1,3 @@ -/* eslint import/prefer-default-export: off */ // Replacing sensitive data inside error message // todo: split main.ts file and make proper structure export const wrapErrorMessageSensitiveData = (e: Error) => { diff --git a/src/webviews/src/modules/manual-connection/ManualConnection.spec.tsx b/src/webviews/src/modules/manual-connection/ManualConnection.spec.tsx index 0bdfe6f5..b3be99d3 100644 --- a/src/webviews/src/modules/manual-connection/ManualConnection.spec.tsx +++ b/src/webviews/src/modules/manual-connection/ManualConnection.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/destructuring-assignment */ import React from 'react' import { instance, mock } from 'ts-mockito' import { TelemetryEvent } from 'uiSrc/utils'