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..e709da2c 100644 --- a/.github/workflows/pipeline-build-windows.yml +++ b/.github/workflows/pipeline-build-windows.yml @@ -24,8 +24,17 @@ 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 }}" >> ${{ 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' @@ -49,3 +58,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..e8d76264 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,7 +10,16 @@ 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' + +export enum UrlHandlingActions { + OAuthCallback = '/cloud/oauth/callback', + Connect = '/databases/connect', +} 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/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 00000000..0610a302 Binary files /dev/null and b/src/lib/cloud_oauth_callback/favicon.png differ 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 00000000..6214da69 Binary files /dev/null and b/src/lib/cloud_oauth_callback/fonts/Graphik-Medium.woff2 differ 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 00000000..b1fd3d4b Binary files /dev/null and b/src/lib/cloud_oauth_callback/fonts/Graphik-Regular.woff2 differ diff --git a/src/lib/cloud_oauth_callback/index.js b/src/lib/cloud_oauth_callback/index.js new file mode 100644 index 00000000..8664889d --- /dev/null +++ b/src/lib/cloud_oauth_callback/index.js @@ -0,0 +1,27 @@ +const protocol = 'vscode://' +const extension = 'redis.redis-for-vscode/' +const callbackUrl = 'cloud/oauth/callback' + +const openAppButton = document.querySelector('#open-app') +// this script is used to open the app from the callback url +// it is also hosted, so changes here won't impact the app +const openApp = (forceOpen) => { + 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/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..d421b41b --- /dev/null +++ b/src/utils/handleUri.ts @@ -0,0 +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) { + if (uri.path.startsWith(UrlHandlingActions.OAuthCallback)) { + const query = Object.fromEntries(new URLSearchParams(uri.query)) + await cloudOauthCallback(query) + return + } + + // 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/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..cbf5e78a --- /dev/null +++ b/src/utils/wrapErrorSensitiveData.ts @@ -0,0 +1,17 @@ +// 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"