diff --git a/.eslintrc.json b/.eslintrc.json index 4a516cd6..1f6c16a2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -36,7 +36,11 @@ "rules": { "no-return-await": "off", "@typescript-eslint/return-await": "error", - "@typescript-eslint/explicit-function-return-type": "error" + "@typescript-eslint/explicit-function-return-type": "error", + "@typescript-eslint/no-misused-promises": [ + "error", + { "checksVoidReturn": false } + ] } } ], diff --git a/package-lock.json b/package-lock.json index 99204622..69884546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,14 @@ "packages/*" ], "dependencies": { - "@deephaven-enterprise/query-utils": "^1.20240723.95-beta", + "@deephaven-enterprise/auth-nodejs": "^1.20240723.111-beta", + "@deephaven-enterprise/query-utils": "^1.20240723.111-beta", "@deephaven/require-jsapi": "file:./packages/require-jsapi", "nanoid": "^5.0.7" }, "devDependencies": { - "@deephaven-enterprise/jsapi-types": "^1.20240723.95-beta", - "@deephaven/jsapi-types": "^1.0.0-dev0.35.3", + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta", + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", "@types/node": "22.5.4", "@types/vscode": "^1.91.0", "@types/ws": "^8.5.10", @@ -436,36 +437,44 @@ "node": ">=12" } }, + "node_modules/@deephaven-enterprise/auth-nodejs": { + "version": "1.20240723.111-beta", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20240723.111-beta.tgz", + "integrity": "sha512-Rm0rdvCNL86idCqKUV85rMT2GC4Hxud7NyUcMVB+enOT1MUQzXVhm1IRTpjTGDEtv6OysyZp0WvY0TQuSoIsmQ==", + "dependencies": { + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta", + "@deephaven/utils": "^0.97.0" + } + }, "node_modules/@deephaven-enterprise/jsapi-types": { - "version": "1.20240723.95-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20240723.95-beta.tgz", - "integrity": "sha512-J9efrlyPEyH505CESu+KKk1kuVxOXmcikyamVRm+znVPD4/OE0u/WkxZzuVchKyCvnc1pes2o4ykW5yHFVWXTw==", - "dev": true, + "version": "1.20240723.111-beta", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20240723.111-beta.tgz", + "integrity": "sha512-LGpFA79OrTmKXH7MgCFK+aN2StLgp2Ccf2iFo8CzEpqT2fvwq0u2t2lPEX3Y+wn2fBA6Q1Q1Qs2olVEkvDv9wQ==", "dependencies": { - "@deephaven/jsapi-types": "^1.0.0-dev0.34.3" + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" } }, "node_modules/@deephaven-enterprise/query-utils": { - "version": "1.20240723.95-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20240723.95-beta.tgz", - "integrity": "sha512-10t0Z+VUB32xY6/QMpRudOrrLeTfQ1etXiQrNz4k41XyjVl8plYDRj4R7esk+9ZwLqNWc2XVsqq91uLx6Fd9LQ==", + "version": "1.20240723.111-beta", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20240723.111-beta.tgz", + "integrity": "sha512-l7je2qWAaaTjJfcxceRvJJm134iGPQLkKpRRCI80UkBzufxd8minKd/han5+7csynzxTXPYhgvp9DXmUNTqhrA==", "dependencies": { - "@deephaven/jsapi-types": "^1.0.0-dev0.34.3", - "@deephaven/log": "^0.95.0", - "@deephaven/utils": "^0.95.0", + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0", "@internationalized/date": "^3.5.5", "shortid": "^2.2.16" } }, "node_modules/@deephaven/jsapi-types": { - "version": "1.0.0-dev0.35.3", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.35.3.tgz", - "integrity": "sha512-0NMh2eRXT16ro4sE/wH0q5+fAdQMitqgKgQ7SwUEtEXq6mp0JWA0xr/x4msdyP3kJB+e6pveTYcgMcHwqrGO/A==" + "version": "1.0.0-dev0.36.1", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.36.1.tgz", + "integrity": "sha512-Q7we+JYMqQrHp3hQfbKF3YmjjCLTjy+D3an8x6IsfVMv7Uv7LqvuA0c/tKCIT19JDa2b9giFWf3TV8apzXry/A==" }, "node_modules/@deephaven/log": { - "version": "0.95.0", - "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.95.0.tgz", - "integrity": "sha512-2p3X+FlSDOlBVCBMy8N1hL6wU4akIDHY1yhJ0mrUkHEwPn3ESAGpLlWWvjY7wHt9mvgFGbjIjpgsQPA9x06EnA==", + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.97.0.tgz", + "integrity": "sha512-JZ9mlQT1xXxRFQDJ3OgodoW1ZZ3AP1Iz9ySokS43bOc5/4Itdv0l8iNoEHgsTrN1HfLmAeQSXUvLXw+2xO9J9w==", "dependencies": { "event-target-shim": "^6.0.2" }, @@ -478,9 +487,9 @@ "link": true }, "node_modules/@deephaven/utils": { - "version": "0.95.0", - "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.95.0.tgz", - "integrity": "sha512-knAh6xxNl1b2dqsCv6Jv87+3gC2OmGqCW/Ub7FXSsoY+qRWO7r5LG7DkVi9S2kLxVgzNH2tWSqSOA7AUt+wLyQ==", + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.97.0.tgz", + "integrity": "sha512-Qp7abGbcwXLXpsVubbiZJIuSa1VO6ePWlfon92/Ni3X92Bp/gsyB4gbogsrNa/3g1rt40d2EAiAVVa5wiy/jCw==", "engines": { "node": ">=16" } @@ -15544,7 +15553,7 @@ "ws": "^8.18.0" }, "devDependencies": { - "@deephaven-enterprise/jsapi-types": "^1.20240723.95-beta" + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta" } } }, @@ -15871,36 +15880,44 @@ "@jridgewell/trace-mapping": "0.3.9" } }, + "@deephaven-enterprise/auth-nodejs": { + "version": "1.20240723.111-beta", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20240723.111-beta.tgz", + "integrity": "sha512-Rm0rdvCNL86idCqKUV85rMT2GC4Hxud7NyUcMVB+enOT1MUQzXVhm1IRTpjTGDEtv6OysyZp0WvY0TQuSoIsmQ==", + "requires": { + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta", + "@deephaven/utils": "^0.97.0" + } + }, "@deephaven-enterprise/jsapi-types": { - "version": "1.20240723.95-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20240723.95-beta.tgz", - "integrity": "sha512-J9efrlyPEyH505CESu+KKk1kuVxOXmcikyamVRm+znVPD4/OE0u/WkxZzuVchKyCvnc1pes2o4ykW5yHFVWXTw==", - "dev": true, + "version": "1.20240723.111-beta", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20240723.111-beta.tgz", + "integrity": "sha512-LGpFA79OrTmKXH7MgCFK+aN2StLgp2Ccf2iFo8CzEpqT2fvwq0u2t2lPEX3Y+wn2fBA6Q1Q1Qs2olVEkvDv9wQ==", "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.34.3" + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" } }, "@deephaven-enterprise/query-utils": { - "version": "1.20240723.95-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20240723.95-beta.tgz", - "integrity": "sha512-10t0Z+VUB32xY6/QMpRudOrrLeTfQ1etXiQrNz4k41XyjVl8plYDRj4R7esk+9ZwLqNWc2XVsqq91uLx6Fd9LQ==", + "version": "1.20240723.111-beta", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20240723.111-beta.tgz", + "integrity": "sha512-l7je2qWAaaTjJfcxceRvJJm134iGPQLkKpRRCI80UkBzufxd8minKd/han5+7csynzxTXPYhgvp9DXmUNTqhrA==", "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.34.3", - "@deephaven/log": "^0.95.0", - "@deephaven/utils": "^0.95.0", + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0", "@internationalized/date": "^3.5.5", "shortid": "^2.2.16" } }, "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.35.3", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.35.3.tgz", - "integrity": "sha512-0NMh2eRXT16ro4sE/wH0q5+fAdQMitqgKgQ7SwUEtEXq6mp0JWA0xr/x4msdyP3kJB+e6pveTYcgMcHwqrGO/A==" + "version": "1.0.0-dev0.36.1", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.36.1.tgz", + "integrity": "sha512-Q7we+JYMqQrHp3hQfbKF3YmjjCLTjy+D3an8x6IsfVMv7Uv7LqvuA0c/tKCIT19JDa2b9giFWf3TV8apzXry/A==" }, "@deephaven/log": { - "version": "0.95.0", - "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.95.0.tgz", - "integrity": "sha512-2p3X+FlSDOlBVCBMy8N1hL6wU4akIDHY1yhJ0mrUkHEwPn3ESAGpLlWWvjY7wHt9mvgFGbjIjpgsQPA9x06EnA==", + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.97.0.tgz", + "integrity": "sha512-JZ9mlQT1xXxRFQDJ3OgodoW1ZZ3AP1Iz9ySokS43bOc5/4Itdv0l8iNoEHgsTrN1HfLmAeQSXUvLXw+2xO9J9w==", "requires": { "event-target-shim": "^6.0.2" } @@ -15908,14 +15925,14 @@ "@deephaven/require-jsapi": { "version": "file:packages/require-jsapi", "requires": { - "@deephaven-enterprise/jsapi-types": "^1.20240723.95-beta", + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta", "ws": "^8.18.0" } }, "@deephaven/utils": { - "version": "0.95.0", - "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.95.0.tgz", - "integrity": "sha512-knAh6xxNl1b2dqsCv6Jv87+3gC2OmGqCW/Ub7FXSsoY+qRWO7r5LG7DkVi9S2kLxVgzNH2tWSqSOA7AUt+wLyQ==" + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.97.0.tgz", + "integrity": "sha512-Qp7abGbcwXLXpsVubbiZJIuSa1VO6ePWlfon92/Ni3X92Bp/gsyB4gbogsrNa/3g1rt40d2EAiAVVa5wiy/jCw==" }, "@esbuild/aix-ppc64": { "version": "0.24.0", diff --git a/package.json b/package.json index 76f674c8..41664d34 100644 --- a/package.json +++ b/package.json @@ -161,11 +161,20 @@ "command": "vscode-deephaven.downloadLogs", "title": "Deephaven: Download Logs" }, + { + "command": "vscode-deephaven.clearSecretStorage", + "title": "Deephaven: Clear Secrets" + }, { "command": "vscode-deephaven.connectToServer", - "title": "Deephaven: Connect to Server", + "title": "Connect to Server", "icon": "$(plug)" }, + { + "command": "vscode-deephaven.connectToServerOperateAs", + "title": "Connect to Server as Another User", + "icon": "$(account)" + }, { "command": "vscode-deephaven.createNewTextDoc", "title": "New File", @@ -173,13 +182,18 @@ }, { "command": "vscode-deephaven.disconnectEditor", - "title": "Deephaven: Disconnect Editor", + "title": "Disconnect Editor", "icon": "$(debug-disconnect)" }, { "command": "vscode-deephaven.disconnectFromServer", - "title": "Deephaven: Discard Connection", - "icon": "$(trash)" + "title": "Disconnect from Server", + "icon": "$(debug-disconnect)" + }, + { + "command": "vscode-deephaven.generateDHEKeyPair", + "title": "Generate DHE Key Pair", + "icon": "$(key)" }, { "command": "vscode-deephaven.openInBrowser", @@ -188,25 +202,25 @@ }, { "command": "vscode-deephaven.openVariablePanels", - "title": "Deephaven: Open Variable Panels" + "title": "Open Variable Panels" }, { "command": "vscode-deephaven.refreshVariablePanels", - "title": "Deephaven: Refresh Variable Panels" + "title": "Refresh Variable Panels" }, { "command": "vscode-deephaven.refreshServerTree", - "title": "Deephaven: Refresh Server Tree", + "title": "Refresh Server Tree", "icon": "$(refresh)" }, { "command": "vscode-deephaven.refreshServerConnectionTree", - "title": "Deephaven: Refresh Server Connection Tree", + "title": "Refresh Server Connection Tree", "icon": "$(refresh)" }, { - "command": "vscode-deephaven.requestDheUserCredentials", - "title": "Deephaven: Request DHE User Credentials" + "command": "vscode-deephaven.createAuthenticatedClient", + "title": "Create Authenticated Client" }, { "command": "vscode-deephaven.startServer", @@ -633,6 +647,10 @@ "command": "vscode-deephaven.selectConnection", "when": "true" }, + { + "command": "vscode-deephaven.clearSecretStorage", + "when": "true" + }, { "command": "vscode-deephaven.downloadLogs", "when": "true" @@ -649,6 +667,10 @@ "command": "vscode-deephaven.connectToServer", "when": "false" }, + { + "command": "vscode-deephaven.connectToServerOperateAs", + "when": "false" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "false" @@ -657,6 +679,10 @@ "command": "vscode-deephaven.disconnectFromServer", "when": "false" }, + { + "command": "vscode-deephaven.generateDHEKeyPair", + "when:": "false" + }, { "command": "vscode-deephaven.openVariablePanels", "when": "false" @@ -674,7 +700,7 @@ "when": "false" }, { - "command": "vscode-deephaven.requestDheUserCredentials", + "command": "vscode-deephaven.createAuthenticatedClient", "when": "false" } ], @@ -716,9 +742,8 @@ ], "view/item/context": [ { - "command": "vscode-deephaven.connectToServer", - "when": "view == vscode-deephaven.serverTree && (viewItem == isManagedServerDisconnected || viewItem == isServerRunningDisconnected || viewItem == isDHEServerRunning)", - "group": "inline" + "command": "vscode-deephaven.connectToServerOperateAs", + "when": "view == vscode-deephaven.serverTree && viewItem == isDHEServerRunningDisconnected" }, { "command": "vscode-deephaven.createNewTextDoc", @@ -732,12 +757,16 @@ }, { "command": "vscode-deephaven.disconnectFromServer", - "when": "view == vscode-deephaven.serverConnectionTree && viewItem == isConnection", + "when": "(view == vscode-deephaven.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)) || (view == vscode-deephaven.serverConnectionTree && viewItem == isConnection)", "group": "inline@2" }, + { + "command": "vscode-deephaven.generateDHEKeyPair", + "when": "view == vscode-deephaven.serverTree && (viewItem == isDHEServerRunningConnected || viewItem == isDHEServerRunningDisconnected)" + }, { "command": "vscode-deephaven.openInBrowser", - "when": "view == vscode-deephaven.serverTree && (viewItem == isManagedServerConnected || viewItem == isServerRunningConnected || viewItem == isServerRunningDisconnected || viewItem == isManagedServerDisconnected || viewItem == isDHEServerRunning)", + "when": "view == vscode-deephaven.serverTree && (viewItem == isManagedServerConnected || viewItem == isManagedServerDisconnected || viewItem == isServerRunningConnected || viewItem == isServerRunningDisconnected || viewItem == isDHEServerRunningConnected || viewItem == isDHEServerRunningDisconnected)", "group": "inline" }, { @@ -781,9 +810,15 @@ ] } }, + "dependencies": { + "@deephaven-enterprise/auth-nodejs": "^1.20240723.111-beta", + "@deephaven-enterprise/query-utils": "^1.20240723.111-beta", + "@deephaven/require-jsapi": "file:./packages/require-jsapi", + "nanoid": "^5.0.7" + }, "devDependencies": { - "@deephaven-enterprise/jsapi-types": "^1.20240723.95-beta", - "@deephaven/jsapi-types": "^1.0.0-dev0.35.3", + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta", + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", "@types/node": "22.5.4", "@types/vscode": "^1.91.0", "@types/ws": "^8.5.10", @@ -809,11 +844,6 @@ "wdio-ctrf-json-reporter": "^0.0.10", "wdio-vscode-service": "^6.1.0" }, - "dependencies": { - "@deephaven-enterprise/query-utils": "^1.20240723.95-beta", - "@deephaven/require-jsapi": "file:./packages/require-jsapi", - "nanoid": "^5.0.7" - }, "overrides": { "event-target-shim": "^6.0.2" } diff --git a/packages/require-jsapi/package.json b/packages/require-jsapi/package.json index fc8c7e3b..0c459234 100644 --- a/packages/require-jsapi/package.json +++ b/packages/require-jsapi/package.json @@ -20,6 +20,6 @@ "ws": "^8.18.0" }, "devDependencies": { - "@deephaven-enterprise/jsapi-types": "^1.20240723.95-beta" + "@deephaven-enterprise/jsapi-types": "^1.20240723.111-beta" } } diff --git a/packages/require-jsapi/src/serverUtils.ts b/packages/require-jsapi/src/serverUtils.ts index a4012eb4..117b6e99 100644 --- a/packages/require-jsapi/src/serverUtils.ts +++ b/packages/require-jsapi/src/serverUtils.ts @@ -43,6 +43,11 @@ export async function downloadFromURL( }); res.on('end', async () => { + if (res.statusCode === 404) { + reject(`File not found: "${url}"`); + return; + } + resolve(file); }); }) diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index fd3099ba..40573747 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -6,6 +6,10 @@ */ import { vi } from 'vitest'; +export class EventEmitter { + fire = vi.fn().mockName('fire'); +} + export enum QuickPickItemKind { Separator = -1, Default = 0, diff --git a/src/common/commands.ts b/src/common/commands.ts index 06541360..bbbaedef 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -1,18 +1,31 @@ import { EXTENSION_ID } from './constants'; -export const CONNECT_TO_SERVER_CMD = `${EXTENSION_ID}.connectToServer`; -export const CREATE_NEW_TEXT_DOC_CMD = `${EXTENSION_ID}.createNewTextDoc`; -export const DISCONNECT_EDITOR_CMD = `${EXTENSION_ID}.disconnectEditor`; -export const DISCONNECT_FROM_SERVER_CMD = `${EXTENSION_ID}.disconnectFromServer`; -export const DOWNLOAD_LOGS_CMD = `${EXTENSION_ID}.downloadLogs`; -export const OPEN_IN_BROWSER_CMD = `${EXTENSION_ID}.openInBrowser`; -export const OPEN_VARIABLE_PANELS_CMD = `${EXTENSION_ID}.openVariablePanels`; -export const REFRESH_SERVER_TREE_CMD = `${EXTENSION_ID}.refreshServerTree`; -export const REFRESH_SERVER_CONNECTION_TREE_CMD = `${EXTENSION_ID}.refreshServerConnectionTree`; -export const REFRESH_VARIABLE_PANELS_CMD = `${EXTENSION_ID}.refreshVariablePanels`; -export const REQUEST_DHE_USER_CREDENTIALS_CMD = `${EXTENSION_ID}.requestDheUserCredentials`; -export const RUN_CODE_COMMAND = `${EXTENSION_ID}.runCode`; -export const RUN_SELECTION_COMMAND = `${EXTENSION_ID}.runSelection`; -export const SELECT_CONNECTION_COMMAND = `${EXTENSION_ID}.selectConnection`; -export const START_SERVER_CMD = `${EXTENSION_ID}.startServer`; -export const STOP_SERVER_CMD = `${EXTENSION_ID}.stopServer`; +/** + * Create a command string prefixed with the extension id. + * @param cmd The command string suffix. + */ +function cmd(cmd: T): `${typeof EXTENSION_ID}.${T}` { + return `${EXTENSION_ID}.${cmd}`; +} + +export const CLEAR_SECRET_STORAGE_CMD = cmd('clearSecretStorage'); +export const CONNECT_TO_SERVER_CMD = cmd('connectToServer'); +export const CONNECT_TO_SERVER_OPERATE_AS_CMD = cmd('connectToServerOperateAs'); +export const CREATE_AUTHENTICATED_CLIENT_CMD = cmd('createAuthenticatedClient'); +export const CREATE_NEW_TEXT_DOC_CMD = cmd('createNewTextDoc'); +export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); +export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); +export const DOWNLOAD_LOGS_CMD = cmd('downloadLogs'); +export const GENERATE_DHE_KEY_PAIR_CMD = cmd('generateDHEKeyPair'); +export const OPEN_IN_BROWSER_CMD = cmd('openInBrowser'); +export const OPEN_VARIABLE_PANELS_CMD = cmd('openVariablePanels'); +export const REFRESH_SERVER_TREE_CMD = cmd('refreshServerTree'); +export const REFRESH_SERVER_CONNECTION_TREE_CMD = cmd( + 'refreshServerConnectionTree' +); +export const REFRESH_VARIABLE_PANELS_CMD = cmd('refreshVariablePanels'); +export const RUN_CODE_COMMAND = cmd('runCode'); +export const RUN_SELECTION_COMMAND = cmd('runSelection'); +export const SELECT_CONNECTION_COMMAND = cmd('selectConnection'); +export const START_SERVER_CMD = cmd('startServer'); +export const STOP_SERVER_CMD = cmd('stopServer'); diff --git a/src/common/constants.ts b/src/common/constants.ts index c7afcb3e..7f670b6c 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -97,7 +97,8 @@ export const PIP_SERVER_STATUS_DIRECTORY = 'pip-server-status'; export const SERVER_TREE_ITEM_CONTEXT = { canStartServer: 'canStartServer', - isDHEServerRunning: 'isDHEServerRunning', + isDHEServerRunningConnected: 'isDHEServerRunningConnected', + isDHEServerRunningDisconnected: 'isDHEServerRunningDisconnected', isManagedServerConnected: 'isManagedServerConnected', isManagedServerConnecting: 'isManagedServerConnecting', isManagedServerDisconnected: 'isManagedServerDisconnected', diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 53218df7..c690a8d3 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -1,12 +1,10 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; -import type { - EnterpriseDhType as DheType, - EnterpriseClient, - LoginCredentials as DheLoginCredentials, -} from '@deephaven-enterprise/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; import { + CLEAR_SECRET_STORAGE_CMD, CONNECT_TO_SERVER_CMD, + CONNECT_TO_SERVER_OPERATE_AS_CMD, CREATE_NEW_TEXT_DOC_CMD, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, @@ -41,12 +39,12 @@ import { } from '../providers'; import { DhcServiceFactory, - DheClientCache, DheJsApiCache, DheService, DheServiceCache, DhService, PanelService, + SecretService, ServerManager, URLMap, } from '../services'; @@ -55,11 +53,13 @@ import type { Disposable, IAsyncCacheService, IConfigService, + IDheClientFactory, IDheService, IDheServiceFactory, IDhService, IDhServiceFactory, IPanelService, + ISecretService, IServerManager, IToastService, Lazy, @@ -74,6 +74,12 @@ import { ConnectionController } from './ConnectionController'; import { PipServerController } from './PipServerController'; import { PanelController } from './PanelController'; import { UserLoginController } from './UserLoginController'; +import { + createClient as createDheClient, + getWsUrl, + type AuthenticatedClient as DheAuthenticatedClient, + type UnauthenticatedClient as DheUnauthenticatedClient, +} from '@deephaven-enterprise/auth-nodejs'; const logger = new Logger('ExtensionController'); @@ -84,6 +90,7 @@ export class ExtensionController implements Disposable { this.initializeDiagnostics(); this.initializeConfig(); + this.initializeSecrets(); this.initializeCodeLenses(); this.initializeHoverProviders(); this.initializeMessaging(); @@ -110,9 +117,8 @@ export class ExtensionController implements Disposable { private _connectionController: ConnectionController | null = null; private _coreCredentialsCache: URLMap> | null = null; - private _dheClientCache: IAsyncCacheService | null = - null; - private _dheCredentialsCache: URLMap | null = null; + private _dheClientCache: URLMap | null = null; + private _dheClientFactory: IDheClientFactory | null = null; private _dheServiceCache: IAsyncCacheService | null = null; private _panelController: PanelController | null = null; private _panelService: IPanelService | null = null; @@ -120,6 +126,7 @@ export class ExtensionController implements Disposable { private _dhcServiceFactory: IDhServiceFactory | null = null; private _dheJsApiCache: IAsyncCacheService | null = null; private _dheServiceFactory: IDheServiceFactory | null = null; + private _secretService: ISecretService | null = null; private _serverManager: IServerManager | null = null; private _userLoginController: UserLoginController | null = null; @@ -168,6 +175,13 @@ export class ExtensionController implements Disposable { ); }; + /** + * Initialize secrets. + */ + initializeSecrets = (): void => { + this._secretService = new SecretService(this._context.secrets); + }; + /** * Initialize connection controller. */ @@ -223,10 +237,16 @@ export class ExtensionController implements Disposable { * Initialize user login controller. */ initializeUserLoginController = (): void => { - assertDefined(this._dheCredentialsCache, 'dheCredentialsCache'); + assertDefined(this._dheClientCache, 'dheClientCache'); + assertDefined(this._dheClientFactory, 'dheClientFactory'); + assertDefined(this._secretService, 'secretService'); + assertDefined(this._toaster, 'toaster'); this._userLoginController = new UserLoginController( - this._dheCredentialsCache + this._dheClientCache, + this._dheClientFactory, + this._secretService, + this._toaster ); this._context.subscriptions.push(this._userLoginController); @@ -291,13 +311,19 @@ export class ExtensionController implements Disposable { assertDefined(this._toaster, 'toaster'); this._coreCredentialsCache = new URLMap>(); - this._dheCredentialsCache = new URLMap(); this._dheJsApiCache = new DheJsApiCache(); this._context.subscriptions.push(this._dheJsApiCache); - this._dheClientCache = new DheClientCache(this._dheJsApiCache); - this._context.subscriptions.push(this._dheClientCache); + this._dheClientFactory = async ( + url: URL + ): Promise => { + assertDefined(this._dheJsApiCache, 'dheJsApiCache'); + const dhe = await this._dheJsApiCache.get(url); + return createDheClient(dhe, getWsUrl(url)); + }; + + this._dheClientCache = new URLMap(); this._panelService = new PanelService(); this._context.subscriptions.push(this._panelService); @@ -314,8 +340,8 @@ export class ExtensionController implements Disposable { this._config, this._coreCredentialsCache, this._dheClientCache, - this._dheCredentialsCache, - this._dheJsApiCache + this._dheJsApiCache, + this._toaster ); this._dheServiceCache = new DheServiceCache(this._dheServiceFactory); @@ -325,6 +351,7 @@ export class ExtensionController implements Disposable { this._config, this._coreCredentialsCache, this._dhcServiceFactory, + this._dheClientCache, this._dheServiceCache, this._outputChannel, this._toaster @@ -374,9 +401,18 @@ export class ExtensionController implements Disposable { initializeCommands = (): void => { assertDefined(this._connectionController, 'connectionController'); + /** Clear secret storage */ + this.registerCommand(CLEAR_SECRET_STORAGE_CMD, this.onClearSecretStorage); + /** Create server connection */ this.registerCommand(CONNECT_TO_SERVER_CMD, this.onConnectToServer); + /** Create server connection operating as another user */ + this.registerCommand( + CONNECT_TO_SERVER_OPERATE_AS_CMD, + this.onConnectToServerOperateAs + ); + /** Create new document */ this.registerCommand(CREATE_NEW_TEXT_DOC_CMD, this.onCreateNewDocument); @@ -522,10 +558,21 @@ export class ExtensionController implements Disposable { this._serverManager?.updateStatus(); }; + /** + * Handle clearing secret storage. + */ + onClearSecretStorage = async (): Promise => { + await this._secretService?.clearStorage(); + this._toaster?.info('Stored secrets have been removed.'); + }; + /** * Handle connecting to a server */ - onConnectToServer = async (serverState: ServerState): Promise => { + onConnectToServer = async ( + serverState: ServerState, + operateAsAnotherUser?: boolean + ): Promise => { const languageId = vscode.window.activeTextEditor?.document.languageId; // DHE servers need to specify the console type for each worker creation. @@ -533,7 +580,21 @@ export class ExtensionController implements Disposable { const workerConsoleType = serverState.type === 'DHE' ? getConsoleType(languageId) : undefined; - this._serverManager?.connectToServer(serverState.url, workerConsoleType); + this._serverManager?.connectToServer( + serverState.url, + workerConsoleType, + operateAsAnotherUser + ); + }; + + /** + * Handle connecting to a server as another user. + * @param serverState + */ + onConnectToServerOperateAs = async ( + serverState: ServerState + ): Promise => { + this.onConnectToServer(serverState, true); }; /** @@ -566,9 +627,30 @@ export class ExtensionController implements Disposable { * Handle disconnecting from a server. */ onDisconnectFromServer = async ( - connectionState: ConnectionState + serverOrConnectionState: ServerState | ConnectionState ): Promise => { - this._serverManager?.disconnectFromServer(connectionState.serverUrl); + // ConnectionState (connection only disconnect) + if ('serverUrl' in serverOrConnectionState) { + this._serverManager?.disconnectFromServer( + serverOrConnectionState.serverUrl + ); + return; + } + + // DHC ServerState + if (serverOrConnectionState.type === 'DHC') { + this._coreCredentialsCache?.delete(serverOrConnectionState.url); + + await this._serverManager?.disconnectFromServer( + serverOrConnectionState.url + ); + } + // DHE ServerState + else { + await this._serverManager?.disconnectFromDHEServer( + serverOrConnectionState.url + ); + } }; /** diff --git a/src/controllers/UserLoginController.ts b/src/controllers/UserLoginController.ts index 52202abc..de67aa99 100644 --- a/src/controllers/UserLoginController.ts +++ b/src/controllers/UserLoginController.ts @@ -1,60 +1,183 @@ -import * as vscode from 'vscode'; +import { + generateBase64KeyPair, + loginClientWithKeyPair, + loginClientWithPassword, + uploadPublicKey, + type AuthenticatedClient as DheAuthenticatedClient, + type Username, +} from '@deephaven-enterprise/auth-nodejs'; import type { URLMap } from '../services'; import { ControllerBase } from './ControllerBase'; -import type { LoginCredentials as DheLoginCredentials } from '@deephaven-enterprise/jsapi-types'; -import { REQUEST_DHE_USER_CREDENTIALS_CMD } from '../common'; +import { + CREATE_AUTHENTICATED_CLIENT_CMD, + GENERATE_DHE_KEY_PAIR_CMD, +} from '../common'; +import { Logger, runUserLoginWorkflow } from '../util'; +import type { + IDheClientFactory, + ISecretService, + IToastService, + ServerState, +} from '../types'; +import { hasInteractivePermission } from '../dh/dhe'; + +const logger = new Logger('UserLoginController'); /** * Controller for user login. */ export class UserLoginController extends ControllerBase { - constructor(dheCredentialsCache: URLMap) { + constructor( + dheClientCache: URLMap, + dheClientFactory: IDheClientFactory, + secretService: ISecretService, + toastService: IToastService + ) { super(); - this.dheCredentialsCache = dheCredentialsCache; + + this.dheClientCache = dheClientCache; + this.dheClientFactory = dheClientFactory; + this.secretService = secretService; + this.toast = toastService; + + this.registerCommand( + GENERATE_DHE_KEY_PAIR_CMD, + this.onDidRequestGenerateDheKeyPair + ); this.registerCommand( - REQUEST_DHE_USER_CREDENTIALS_CMD, - this.onDidRequestDheUserCredentials + CREATE_AUTHENTICATED_CLIENT_CMD, + this.onCreateAuthenticatedClient ); } - private readonly dheCredentialsCache: URLMap; + private readonly dheClientCache: URLMap; + private readonly dheClientFactory: IDheClientFactory; + private readonly secretService: ISecretService; + private readonly toast: IToastService; /** - * Handle the request for DHE user credentials. If credentials are provided, - * they will be stored in the credentials cache. - * @param serverUrl The server URL to request credentials for. - * @returns A promise that resolves when the credentials have been provided or declined. + * Handle request for generating a DHE key pair. + * @param serverState The server state to generate the key pair for. */ - onDidRequestDheUserCredentials = async (serverUrl: URL): Promise => { - // Remove any existing credentials for the server - this.dheCredentialsCache.delete(serverUrl); + onDidRequestGenerateDheKeyPair = async ( + serverState: ServerState + ): Promise => { + const serverUrl = serverState.url; + + const title = 'Generate Private Key'; - const username = await vscode.window.showInputBox({ - placeHolder: 'Username', - prompt: 'Enter your Deephaven username', + const userLoginPreferences = + await this.secretService.getUserLoginPreferences(serverUrl); + + const credentials = await runUserLoginWorkflow({ + title, + userLoginPreferences, }); - if (username == null) { + // Cancelled by user + if (credentials == null) { return; } - const token = await vscode.window.showInputBox({ - placeHolder: 'Password', - prompt: 'Enter your Deephaven password', - password: true, + const keyPair = generateBase64KeyPair(); + const { type, publicKey } = keyPair; + + const dheClient = await loginClientWithPassword( + await this.dheClientFactory(serverUrl), + credentials + ); + + await uploadPublicKey({ + dheClient, + userName: credentials.username, + comment: `Generated by VSCode ${new Date().toISOString()}`, + publicKey, + type, + }); + + // Get existing server keys or create a new object + const serverKeys = await this.secretService.getServerKeys(serverUrl); + + // Store the new private key for the user + await this.secretService.storeServerKeys(serverUrl, { + ...serverKeys, + [credentials.username]: keyPair, }); - if (token == null) { + this.toast.info( + `Successfully generated a new key pair for ${credentials.username}.` + ); + }; + + /** + * Create an authenticated client. + * @param serverUrl The server URL to create a client for. + * @param operateAsAnotherUser Whether to operate as another user. + * @returns A promise that resolves when the client has been created or failed. + */ + onCreateAuthenticatedClient = async ( + serverUrl: URL, + operateAsAnotherUser: boolean + ): Promise => { + const title = 'Login'; + + const secretKeys = await this.secretService.getServerKeys(serverUrl); + const userLoginPreferences = + await this.secretService.getUserLoginPreferences(serverUrl); + + const privateKeyUserNames = Object.keys(secretKeys) as Username[]; + + const credentials = await runUserLoginWorkflow({ + title, + userLoginPreferences, + privateKeyUserNames, + showOperatesAs: operateAsAnotherUser, + }); + + // Cancelled by user + if (credentials == null) { + this.toast.info('Login cancelled.'); return; } - const dheCredentials: DheLoginCredentials = { - username, - token, - type: 'password', - }; + const { username, operateAs = username } = credentials; - this.dheCredentialsCache.set(serverUrl, dheCredentials); + await this.secretService.storeUserLoginPreferences(serverUrl, { + lastLogin: username, + operateAsUser: { + ...userLoginPreferences.operateAsUser, + [username]: operateAs, + }, + }); + + const dheClient = await this.dheClientFactory(serverUrl); + + try { + const authenticatedClient = + credentials.type === 'password' + ? await loginClientWithPassword(dheClient, credentials) + : await loginClientWithKeyPair(dheClient, { + ...credentials, + keyPair: (await this.secretService.getServerKeys(serverUrl))?.[ + username + ], + }); + + if (!(await hasInteractivePermission(authenticatedClient))) { + throw new Error('User does not have interactive permissions.'); + } + + this.dheClientCache.set(serverUrl, authenticatedClient); + } catch (err) { + logger.error('An error occurred while connecting to DHE server:', err); + this.dheClientCache.delete(serverUrl); + + this.toast.error('Login failed. Please check your credentials.'); + + if (credentials.type === 'keyPair') { + await this.secretService.deleteUserServerKeys(serverUrl, username); + } + } }; } diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 804bb38a..a02471ba 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -2,7 +2,6 @@ import type { dh as DhcType } from '@deephaven/jsapi-types'; import type { EnterpriseDhType as DheType, EditableQueryInfo, - EnterpriseClient, QueryInfo, TypeSpecificFields, } from '@deephaven-enterprise/jsapi-types'; @@ -22,42 +21,20 @@ import { INTERACTIVE_CONSOLE_QUERY_TYPE, INTERACTIVE_CONSOLE_TEMPORARY_QUEUE_NAME, } from '../common'; +import type { AuthenticatedClient as DheAuthenticatedClient } from '@deephaven-enterprise/auth-nodejs'; export type IDraftQuery = EditableQueryInfo & { isClientSide: boolean; draftOwner: string; }; -/** - * Create DHE client. - * @param dhe DHE JsApi - * @param serverUrl Server URL - * @returns A promise that resolves to the DHE client. - */ -export async function createDheClient( - dhe: DheType, - serverUrl: URL -): Promise { - const dheClient = new dhe.Client(serverUrl.toString()); - - return new Promise(resolve => { - const unsubscribe = dheClient.addEventListener( - dhe.Client.EVENT_CONNECT, - () => { - unsubscribe(); - resolve(dheClient); - } - ); - }); -} - /** * Get credentials for a Core+ worker associated with a given DHE client. * @param client The DHE client. * @returns A promise that resolves to the worker credentials. */ export async function getWorkerCredentials( - client: EnterpriseClient + client: DheAuthenticatedClient ): Promise { const token = await client.createAuthToken('RemoteQueryProcessor'); return { @@ -66,28 +43,13 @@ export async function getWorkerCredentials( }; } -/** - * Get the WebSocket URL for a DHE server URL. - * @param serverUrl The DHE server URL. - * @returns The WebSocket URL. - */ -export function getWsUrl(serverUrl: URL): URL { - const url = new URL('/socket', serverUrl); - if (url.protocol === 'http:') { - url.protocol = 'ws:'; - } else { - url.protocol = 'wss:'; - } - return url; -} - /** * Determine if the logged in user has permission to interact with the UI. * @param dheClient The DHE client. * @returns A promise that resolves to true if the user has permission to interact with the UI. */ export async function hasInteractivePermission( - dheClient: EnterpriseClient + dheClient: DheAuthenticatedClient ): Promise { // TODO: Retrieve these group names from the server: // https://deephaven.atlassian.net/browse/DH-9418 @@ -115,7 +77,7 @@ export async function hasInteractivePermission( */ export async function createInteractiveConsoleQuery( tagId: UniqueID, - dheClient: EnterpriseClient, + dheClient: DheAuthenticatedClient, workerConfig: WorkerConfig = {}, consoleType?: ConsoleType ): Promise { @@ -210,7 +172,7 @@ export async function createInteractiveConsoleQuery( * @param querySerials Serials of queries to delete. */ export async function deleteQueries( - dheClient: EnterpriseClient, + dheClient: DheAuthenticatedClient, querySerials: QuerySerial[] ): Promise { await dheClient.deleteQueries(querySerials); @@ -227,7 +189,7 @@ export async function deleteQueries( export async function getWorkerInfoFromQuery( tagId: UniqueID, dhe: DheType, - dheClient: EnterpriseClient, + dheClient: DheAuthenticatedClient, querySerial: QuerySerial ): Promise { // The query will go through multiple config updates before the worker is ready. @@ -239,7 +201,7 @@ export async function getWorkerInfoFromQuery( ({ detail: queryInfo }: CustomEvent) => { if ( queryInfo.serial === querySerial && - queryInfo.designated?.grpcUrl != null + queryInfo.designated?.status === 'Running' ) { resolve(queryInfo); } diff --git a/src/services/DhcService.ts b/src/services/DhcService.ts index 9fca6f9d..fbc63de6 100644 --- a/src/services/DhcService.ts +++ b/src/services/DhcService.ts @@ -60,6 +60,7 @@ export class DhcService extends DhService { // TODO: Login flow UI should be a separate concern // deephaven/vscode-deephaven/issues/151 token: await vscode.window.showInputBox({ + ignoreFocusOut: true, placeHolder: 'Pre-Shared Key', prompt: 'Enter your Deephaven pre-shared key', password: true, diff --git a/src/services/DheService.ts b/src/services/DheService.ts index b8790297..19ff9cf6 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -1,10 +1,7 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; -import type { - EnterpriseDhType as DheType, - EnterpriseClient, - LoginCredentials as DheLoginCredentials, -} from '@deephaven-enterprise/jsapi-types'; +import type { AuthenticatedClient as DheAuthenticatedClient } from '@deephaven-enterprise/auth-nodejs'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; import { WorkerURL, type ConsoleType, @@ -12,6 +9,7 @@ import { type IConfigService, type IDheService, type IDheServiceFactory, + type IToastService, type Lazy, type QuerySerial, type UniqueID, @@ -25,9 +23,8 @@ import { deleteQueries, getWorkerCredentials, getWorkerInfoFromQuery, - hasInteractivePermission, } from '../dh/dhe'; -import { REQUEST_DHE_USER_CREDENTIALS_CMD } from '../common'; +import { CREATE_AUTHENTICATED_CLIENT_CMD } from '../common'; const logger = new Logger('DheService'); @@ -37,18 +34,19 @@ const logger = new Logger('DheService'); export class DheService implements IDheService { /** * Creates a factory function that can be used to create DheService instances. + * @param configService Configuration service. * @param coreCredentialsCache Core credentials cache. * @param dheClientCache DHE client cache. - * @param dheCredentialsCache DHE credentials cache. * @param dheJsApiCache DHE JS API cache. + * @param toaster Toast service for notifications. * @returns A factory function that can be used to create DheService instances. */ static factory = ( configService: IConfigService, coreCredentialsCache: URLMap>, - dheClientCache: IAsyncCacheService, - dheCredentialsCache: URLMap, - dheJsApiCache: IAsyncCacheService + dheClientCache: URLMap, + dheJsApiCache: IAsyncCacheService, + toaster: IToastService ): IDheServiceFactory => { return { create: (serverUrl: URL): IDheService => @@ -57,8 +55,8 @@ export class DheService implements IDheService { configService, coreCredentialsCache, dheClientCache, - dheCredentialsCache, - dheJsApiCache + dheJsApiCache, + toaster ), }; }; @@ -71,30 +69,32 @@ export class DheService implements IDheService { serverUrl: URL, configService: IConfigService, coreCredentialsCache: URLMap>, - dheClientCache: IAsyncCacheService, - dheCredentialsCache: URLMap, - dheJsApiCache: IAsyncCacheService + dheClientCache: URLMap, + dheJsApiCache: IAsyncCacheService, + toaster: IToastService ) { this.serverUrl = serverUrl; this._config = configService; this._coreCredentialsCache = coreCredentialsCache; this._dheClientCache = dheClientCache; - this._dheCredentialsCache = dheCredentialsCache; this._dheJsApiCache = dheJsApiCache; this._querySerialSet = new Set(); + this._toaster = toaster; this._workerInfoMap = new URLMap(); + + this._dheClientCache.onDidChange(this._onDidDheClientCacheInvalidate); } - private _clientPromise: Promise | null = null; + private _clientPromise: Promise | null = null; private _isConnected: boolean = false; private readonly _config: IConfigService; private readonly _coreCredentialsCache: URLMap< Lazy >; - private readonly _dheClientCache: IAsyncCacheService; - private readonly _dheCredentialsCache: URLMap; + private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; private readonly _querySerialSet: Set; + private readonly _toaster: IToastService; private readonly _workerInfoMap: URLMap; readonly serverUrl: URL; @@ -108,41 +108,23 @@ export class DheService implements IDheService { /** * Initialize DHE client and login. + * @param operateAsAnotherUser Whether to operate as another user. * @returns DHE client or null if initialization failed. */ - private _initClient = async (): Promise => { - const dheClient = await this._dheClientCache.get(this.serverUrl); - - if (!this._dheCredentialsCache.has(this.serverUrl)) { + private _initClient = async ( + operateAsAnotherUser: boolean + ): Promise => { + if (!this._dheClientCache.has(this.serverUrl)) { await vscode.commands.executeCommand( - REQUEST_DHE_USER_CREDENTIALS_CMD, - this.serverUrl + CREATE_AUTHENTICATED_CLIENT_CMD, + this.serverUrl, + operateAsAnotherUser ); - - if (!this._dheCredentialsCache.has(this.serverUrl)) { - logger.error( - 'Failed to get DHE credentials for server:', - this.serverUrl.toString() - ); - return null; - } } - const dheCredentials = this._dheCredentialsCache.get(this.serverUrl)!; - - try { - await dheClient.login(dheCredentials); - } catch (err) { - logger.error('An error occurred while connecting to DHE server:', err); - return null; - } - - if (!hasInteractivePermission(dheClient)) { - logger.error('User does not have permission to run queries.'); - return null; - } + const maybeClient = await this._dheClientCache.get(this.serverUrl); - return dheClient; + return maybeClient ?? null; }; /** @@ -159,6 +141,14 @@ export class DheService implements IDheService { } }; + private _onDidDheClientCacheInvalidate = (url: URL): void => { + if (url.toString() === this.serverUrl.toString()) { + // Reset the client promise so that the next call to `getClient` can + // reinitialize it if necessary. + this._clientPromise = null; + } + }; + /** * Get the config for creating new workers. * @returns Worker config or undefined if not found. @@ -182,17 +172,26 @@ export class DheService implements IDheService { /** * Get DHE client. * @param initializeIfNull Whether to initialize client if it's not already initialized. + * @param operateAsAnotherUser Whether to operate as another user. * @returns DHE client or null if not initialized. */ - getClient = async ( - initializeIfNull: boolean - ): Promise => { + async getClient( + initializeIfNull: false + ): Promise; + async getClient( + initializeIfNull: true, + operateAsAnotherUser: boolean + ): Promise; + async getClient( + initializeIfNull: boolean, + operateAsAnotherUser = false + ): Promise { if (this._clientPromise == null) { if (!initializeIfNull) { return null; } - this._clientPromise = this._initClient(); + this._clientPromise = this._initClient(operateAsAnotherUser); } const dheClient = await this._clientPromise; @@ -203,7 +202,7 @@ export class DheService implements IDheService { } return dheClient; - }; + } /** * Create an InteractiveConsole query and get worker info from it. @@ -215,7 +214,7 @@ export class DheService implements IDheService { tagId: UniqueID, consoleType?: ConsoleType ): Promise => { - const dheClient = await this.getClient(true); + const dheClient = await this.getClient(true, false); if (dheClient == null) { const msg = 'Failed to create worker because DHE client failed to initialize.'; @@ -242,7 +241,6 @@ export class DheService implements IDheService { if (workerInfo == null) { throw new Error('Failed to create worker.'); } - const workerUrl = new URL(workerInfo.grpcUrl); this._coreCredentialsCache.set(workerUrl, () => getWorkerCredentials(dheClient) diff --git a/src/services/SecretService.ts b/src/services/SecretService.ts new file mode 100644 index 00000000..bb227441 --- /dev/null +++ b/src/services/SecretService.ts @@ -0,0 +1,150 @@ +import type { SecretStorage } from 'vscode'; +import type { Username } from '@deephaven-enterprise/auth-nodejs'; +import type { + ISecretService, + UserKeyPairs, + UserLoginPreferences, +} from '../types'; + +const OPERATE_AS_USER_KEY = 'operateAsUser' as const; +const SERVER_KEYS_KEY = 'serverKeys' as const; + +/** + * Wrapper around `vscode.SecretStorage` for storing and retrieving secrets. We + * are storing everything as known keys so that we can easily find and delete + * them. There don't appear to be any apis to delete all secrets at once or to + * determine which keys exist. + * NOTE: For debugging, the secret store contents can be dumped to devtools + * console via: + * > Developer: Log Storage Database Contents + */ +export class SecretService implements ISecretService { + constructor(secrets: SecretStorage) { + this._secrets = secrets; + } + + private readonly _secrets: SecretStorage; + + /** + * Parse a stored JSON string value to an object. + * @param key Secret storage key + * @returns An object of type T or null if a value cannot be found or parsed. + */ + private _getJson = async (key: string): Promise => { + const raw = await this._secrets.get(key); + if (raw == null) { + return null; + } + + try { + return JSON.parse(raw); + } catch { + await this._secrets.delete(key); + return null; + } + }; + + /** + * Store a JSON-serializable value. + * @param key Secret storage key + * @param value Value to store + */ + private _storeJson = async (key: string, value: T): Promise => { + return this._secrets.store(key, JSON.stringify(value)); + }; + + /** + * Clear all stored secrets. + */ + clearStorage = async (): Promise => { + await this._secrets.delete(OPERATE_AS_USER_KEY); + await this._secrets.delete(SERVER_KEYS_KEY); + }; + + /** + * Get user login preferences for a given server. + * @param serverUrl The server URL to get the map for. + * @returns The user login preferences for the server. + */ + getUserLoginPreferences = async ( + serverUrl: URL + ): Promise => { + const preferences = + await this._getJson>( + OPERATE_AS_USER_KEY + ); + + return preferences?.[serverUrl.toString()] ?? { operateAsUser: {} }; + }; + + /** + * Store user login preferences for a given server. + * @param serverUrl The server URL to store the map for. + * @param preferences The user login preferences to store. + */ + storeUserLoginPreferences = async ( + serverUrl: URL, + preferences: UserLoginPreferences + ): Promise => { + const existingPreferences = + await this._getJson>( + OPERATE_AS_USER_KEY + ); + + await this._storeJson(OPERATE_AS_USER_KEY, { + ...existingPreferences, + [serverUrl.toString()]: preferences, + }); + }; + + /** + * Delete server keys for a given server + user. + * @param serverUrl The server to delete keys for. + * @param userName The user to delete keys for. + * @returns A promise that resolves when the keys have been deleted. + */ + deleteUserServerKeys = async ( + serverUrl: URL, + userName: Username + ): Promise => { + const existingKeys = await this.getServerKeys(serverUrl); + + if (existingKeys == null) { + return; + } + + delete existingKeys[userName]; + + await this.storeServerKeys(serverUrl, existingKeys); + }; + + /** + * Get a map of user -> key pairs for a given server. + * @param serverUrl The server URL to get the map for + * @returns The map of user -> key pairs or null. + */ + getServerKeys = async (serverUrl: URL): Promise => { + const maybeServerKeys = + await this._getJson>(SERVER_KEYS_KEY); + + return maybeServerKeys?.[serverUrl.toString()] ?? {}; + }; + + /** + * Store a map of user -> key pairs for a given server. + * @param serverUrl The server URL to store the map for. + * @param serverKeys The map of user -> key pairs. + */ + storeServerKeys = async ( + serverUrl: URL, + serverKeys: UserKeyPairs + ): Promise => { + const existingKeys = + await this._getJson>(SERVER_KEYS_KEY); + + await this._storeJson(SERVER_KEYS_KEY, { + ...existingKeys, + [serverUrl.toString()]: serverKeys, + }); + }; +} diff --git a/src/services/SerializedKeyMap.spec.ts b/src/services/SerializedKeyMap.spec.ts index 4ef24eef..9435679d 100644 --- a/src/services/SerializedKeyMap.spec.ts +++ b/src/services/SerializedKeyMap.spec.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; import { SerializedKeyMap } from './SerializedKeyMap'; +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + describe('SerializedKeyMap', () => { const deserializeKey = (key: string): Object => JSON.parse(key); const serializeKey = (key: Object): string => JSON.stringify(key); diff --git a/src/services/SerializedKeyMap.ts b/src/services/SerializedKeyMap.ts index 54e89160..3b592a01 100644 --- a/src/services/SerializedKeyMap.ts +++ b/src/services/SerializedKeyMap.ts @@ -1,3 +1,5 @@ +import * as vscode from 'vscode'; + /** * Base class for Maps that need to store their keys as serialized string values * internally for equality checks. Externally, the keys are deserialized back to @@ -17,6 +19,9 @@ export abstract class SerializedKeyMap { ); } + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + private readonly _map: Map; /** Serialize from a key to a string. */ @@ -30,15 +35,26 @@ export abstract class SerializedKeyMap { } clear(): void { + const keys = [...this.keys()]; this._map.clear(); + keys.forEach(key => this._onDidChange.fire(key)); } get(key: TKey): TValue | undefined { return this._map.get(this.serializeKey(key)); } + getOrThrow(key: TKey): TValue { + const value = this.get(key); + if (value == null) { + throw new Error(`Key not found: ${key}`); + } + return value; + } + set(key: TKey, value: TValue): this { this._map.set(this.serializeKey(key), value); + this._onDidChange.fire(key); return this; } @@ -47,7 +63,13 @@ export abstract class SerializedKeyMap { } delete(key: TKey): boolean { - return this._map.delete(this.serializeKey(key)); + const deleted = this._map.delete(this.serializeKey(key)); + + if (deleted) { + this._onDidChange.fire(key); + } + + return deleted; } forEach( diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index cc9a0cf7..b7b0a00f 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { randomUUID } from 'node:crypto'; +import type { AuthenticatedClient as DheAuthenticatedClient } from '@deephaven-enterprise/auth-nodejs'; import type { dh as DhcType } from '@deephaven/jsapi-types'; import { isDhcServerRunning, @@ -40,6 +41,7 @@ export class ServerManager implements IServerManager { configService: IConfigService, coreCredentialsCache: URLMap>, dhcServiceFactory: IDhServiceFactory, + dheClientCache: URLMap, dheServiceCache: IAsyncCacheService, outputChannel: vscode.OutputChannel, toaster: IToastService @@ -48,6 +50,7 @@ export class ServerManager implements IServerManager { this._connectionMap = new URLMap(); this._coreCredentialsCache = coreCredentialsCache; this._dhcServiceFactory = dhcServiceFactory; + this._dheClientCache = dheClientCache; this._dheServiceCache = dheServiceCache; this._outputChannel = outputChannel; this._serverMap = new URLMap(); @@ -66,6 +69,7 @@ export class ServerManager implements IServerManager { Lazy >; private readonly _dhcServiceFactory: IDhServiceFactory; + private readonly _dheClientCache: URLMap; private readonly _dheServiceCache: IAsyncCacheService; private readonly _outputChannel: vscode.OutputChannel; private readonly _toaster: IToastService; @@ -139,7 +143,8 @@ export class ServerManager implements IServerManager { connectToServer = async ( serverUrl: URL, - workerConsoleType?: ConsoleType + workerConsoleType?: ConsoleType, + operateAsAnotherUser: boolean = false ): Promise => { const serverState = this._serverMap.get(serverUrl); @@ -177,7 +182,7 @@ export class ServerManager implements IServerManager { // Get client. Client will be initialized if it doesn't exist (including // prompting user for login). - if (!(await dheService.getClient(true))) { + if (!(await dheService.getClient(true, operateAsAnotherUser))) { return null; } @@ -279,6 +284,40 @@ export class ServerManager implements IServerManager { this._onDidUpdate.fire(); }; + /** + * Completely disconnect from a DHE server. This including all workers plus + * the primary DHE client connection. + * @param dheServerUrl The URL of the DHE server to disconnect from. + * @returns Promise that resolves when all connections have been discarded. + */ + disconnectFromDHEServer = async (dheServerUrl: URL): Promise => { + const workerUrls = [...this._workerURLToServerURLMap.entries()].filter( + ([, url]) => url.toString() === dheServerUrl.toString() + ); + + for (const [workerUrl] of workerUrls) { + await this.disconnectFromServer(workerUrl); + } + + // Deleting the DHE client needs to happen after worker disposal since an + // active client is needed to dispose workers. + this._dheClientCache.get(dheServerUrl)?.disconnect(); + this._dheClientCache.delete(dheServerUrl); + + const serverState = this._serverMap.get(dheServerUrl); + if (serverState == null) { + return; + } + + this._serverMap.set(dheServerUrl, { + ...serverState, + isConnected: false, + connectionCount: 0, + }); + + this._onDidUpdate.fire(); + }; + disconnectFromServer = async ( serverOrWorkerUrl: URL | WorkerURL ): Promise => { diff --git a/src/services/cache/ByURLAsyncCache.ts b/src/services/cache/ByURLAsyncCache.ts index c4d70889..d2e1f797 100644 --- a/src/services/cache/ByURLAsyncCache.ts +++ b/src/services/cache/ByURLAsyncCache.ts @@ -1,3 +1,4 @@ +import * as vscode from 'vscode'; import type { IAsyncCacheService } from '../../types'; import { isDisposable } from '../../util'; import { URLMap } from '../URLMap'; @@ -13,6 +14,9 @@ export class ByURLAsyncCache this._promiseMap = new URLMap>(); } + private readonly _onDidInvalidate = new vscode.EventEmitter(); + readonly onDidInvalidate = this._onDidInvalidate.event; + private readonly _loader: (url: URL) => Promise; private readonly _promiseMap = new URLMap>(); @@ -28,6 +32,7 @@ export class ByURLAsyncCache invalidate = (url: URL): void => { this._promiseMap.delete(url); + this._onDidInvalidate.fire(url); }; dispose = async (): Promise => { diff --git a/src/services/cache/DheClientCache.ts b/src/services/cache/DheClientCache.ts deleted file mode 100644 index 82a1495c..00000000 --- a/src/services/cache/DheClientCache.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { - EnterpriseDhType as DheType, - EnterpriseClient, -} from '@deephaven-enterprise/jsapi-types'; -import { ByURLAsyncCache } from './ByURLAsyncCache'; -import { createDheClient, getWsUrl } from '../../dh/dhe'; -import type { IAsyncCacheService } from '../../types'; - -/** - * Cache DHE client instances by URL. - */ -export class DheClientCache extends ByURLAsyncCache { - constructor(dheJsApiCache: IAsyncCacheService) { - super(async (url: URL) => { - const dhe = await dheJsApiCache.get(url); - return createDheClient(dhe, getWsUrl(url)); - }); - } -} diff --git a/src/services/cache/index.ts b/src/services/cache/index.ts index e004bc47..9c1c1c90 100644 --- a/src/services/cache/index.ts +++ b/src/services/cache/index.ts @@ -1,4 +1,3 @@ export * from './ByURLAsyncCache'; -export * from './DheClientCache'; export * from './DheJsApiCache'; export * from './DheServiceCache'; diff --git a/src/services/index.ts b/src/services/index.ts index 0971c9a3..e161de37 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -6,6 +6,7 @@ export * from './DhcServiceFactory'; export * from './DheService'; export * from './PanelService'; export * from './PollingService'; +export * from './SecretService'; export * from './SerializedKeyMap'; export * from './ServerManager'; export * from './URIMap'; diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index 77347026..29cd57d4 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -1,5 +1,12 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { + Base64KeyPair, + KeyPairCredentials, + OperateAsUsername, + PasswordCredentials, + Username, +} from '@deephaven-enterprise/auth-nodejs'; // Branded type helpers declare const __brand: unique symbol; @@ -7,6 +14,8 @@ export type Brand = TBase & { readonly [__brand]: T; }; +export type NonEmptyArray = [T, ...T[]]; + export type UniqueID = Brand<'UniqueID', string>; export type Port = Brand<'Port', number>; @@ -37,6 +46,17 @@ export interface EnterpriseConnectionConfig { experimentalWorkerConfig?: WorkerConfig; } +export type LoginWorkflowType = 'login' | 'generatePrivateKey'; +export type LoginWorkflowResult = + | PasswordCredentials + | Omit; + +export type UserKeyPairs = Record; +export type UserLoginPreferences = { + lastLogin?: Username; + operateAsUser: Record; +}; + export type ServerConnectionConfig = | CoreConnectionConfig | EnterpriseConnectionConfig diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index c570eeb4..4e956ca7 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import type { EnterpriseClient } from '@deephaven-enterprise/jsapi-types'; + import type { ConsoleType, CoreConnectionConfig, @@ -15,12 +15,20 @@ import type { WorkerInfo, WorkerURL, UniqueID, + UserKeyPairs, + UserLoginPreferences, } from '../types/commonTypes'; +import type { + AuthenticatedClient as DheAuthenticatedClient, + UnauthenticatedClient as DheUnauthenticatedClient, + Username, +} from '@deephaven-enterprise/auth-nodejs'; export interface IAsyncCacheService extends Disposable { get: (key: TKey) => Promise; has: (key: TKey) => boolean; invalidate: (key: TKey) => void; + onDidInvalidate: vscode.Event; } /** @@ -53,7 +61,11 @@ export interface IDhService } export interface IDheService extends ConnectionState, Disposable { - getClient: (initializeIfNull: boolean) => Promise; + getClient(initializeIfNull: false): Promise; + getClient( + initializeIfNull: true, + operateAsAnotherUser: boolean + ): Promise; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; createWorker: ( tagId: UniqueID, @@ -85,6 +97,9 @@ export type IDhServiceFactory = IFactory< IDhService, [serverUrl: URL, tagId?: UniqueID] >; +export type IDheClientFactory = ( + serverUrl: URL +) => Promise; export type IDheServiceFactory = IFactory; export interface IPanelService extends Disposable { @@ -104,6 +119,21 @@ export interface IPanelService extends Disposable { updateVariables: (url: URL, changes: VariableChanges) => void; } +/** + * Secret service interface. + */ +export interface ISecretService { + deleteUserServerKeys(serverUrl: URL, userName: Username): Promise; + getServerKeys(serverUrl: URL): Promise; + storeServerKeys(serverUrl: URL, serverKeys: UserKeyPairs): Promise; + clearStorage(): Promise; + getUserLoginPreferences(serverUrl: URL): Promise; + storeUserLoginPreferences( + serverUrl: URL, + preferences: UserLoginPreferences + ): Promise; +} + /** * Server manager interface. */ @@ -112,9 +142,11 @@ export interface IServerManager extends Disposable { connectToServer: ( serverUrl: URL, - workerConsoleType?: ConsoleType + workerConsoleType?: ConsoleType, + operateAsAnotherUser?: boolean ) => Promise; disconnectEditor: (uri: vscode.Uri) => void; + disconnectFromDHEServer: (dheServerUrl: URL) => Promise; disconnectFromServer: (serverUrl: URL) => Promise; loadServerConfig: () => Promise; diff --git a/src/types/uiTypes.d.ts b/src/types/uiTypes.d.ts index 0e61aff6..86032961 100644 --- a/src/types/uiTypes.d.ts +++ b/src/types/uiTypes.d.ts @@ -15,3 +15,9 @@ export type ConnectionPickOption = | SeparatorPickItem | ConnectionPickItem<'server', ServerState> | ConnectionPickItem<'connection', TConnection>; + +export type LoginWorkflowPromptType = + | 'authenticationMethod' + | 'user' + | 'password' + | 'operateAs'; diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 7316043f..eeae18d6 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -574,7 +574,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect "command": "vscode-deephaven.connectToServer", "title": "Connect to server", }, - "contextValue": "isDHEServerRunning", + "contextValue": "isDHEServerRunningDisconnected", "description": "", "iconPath": { "color": undefined, @@ -657,7 +657,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect "command": "vscode-deephaven.connectToServer", "title": "Connect to server", }, - "contextValue": "isDHEServerRunning", + "contextValue": "isDHEServerRunningConnected", "description": "(1)", "iconPath": { "color": undefined, diff --git a/src/util/dataUtils.ts b/src/util/dataUtils.ts index 1acb9f3f..99b3fa9c 100644 --- a/src/util/dataUtils.ts +++ b/src/util/dataUtils.ts @@ -1,3 +1,14 @@ +import type { NonEmptyArray } from '../types'; + +/** + * Type guard to check if an array is non-empty. + * @param array + * @returns true if the array is non-empty, false otherwise + */ +export function isNonEmptyArray(array: T[]): array is NonEmptyArray { + return array.length > 0; +} + /** * Create a sort comparator function that compares a stringified property on * 2 objects. diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 6f161eb7..49cae6cb 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -118,7 +118,9 @@ export function getServerContextValue({ if (isRunning) { if (isDHE) { - return SERVER_TREE_ITEM_CONTEXT.isDHEServerRunning; + return isConnected + ? SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningConnected + : SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningDisconnected; } return isConnected @@ -252,7 +254,8 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { const canConnect = contextValue === SERVER_TREE_ITEM_CONTEXT.isManagedServerDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isServerRunningDisconnected || - contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunning; + contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningConnected || + contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningDisconnected; return { label: new URL(urlStr).host, diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 6cdf1535..87ce1531 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -13,8 +13,16 @@ import type { SeparatorPickItem, ConnectionPickOption, ConnectionState, + UserLoginPreferences, } from '../types'; import { sortByStringProp } from './dataUtils'; +import { assertDefined } from './assertUtil'; +import type { + KeyPairCredentials, + OperateAsUsername, + PasswordCredentials, + Username, +} from '@deephaven-enterprise/auth-nodejs'; export interface ConnectionOption { type: ConnectionType; @@ -97,7 +105,9 @@ export async function createConnectionQuickPick( options: ConnectionPickOption[] ): Promise { const result = await vscode.window.showQuickPick(options, { + ignoreFocusOut: true, title: 'Connect Editor', + placeHolder: "Select connection (Press 'Escape' to cancel)", }); if (result == null || !('type' in result)) { @@ -107,6 +117,102 @@ export async function createConnectionQuickPick( return result.data; } +/** + * Run user login workflow that prompts user for credentials. Prompts are + * conditional based on the provided arguments. + * @param title Title for the prompts + * @param userLoginPreferences User login preferences to determine default values + * for user / operate as prompts. + * @param privateKeyUserNames Optional list of private key user names. If provided, + * the authentication method will be prompted to determine if user wants to use + * one of these private keys or username/password. + * @param showOperatesAs Whether to show the operate as prompt. + */ +export async function runUserLoginWorkflow(args: { + title: string; + userLoginPreferences?: UserLoginPreferences; + privateKeyUserNames?: undefined | []; + showOperatesAs?: boolean; +}): Promise; +export async function runUserLoginWorkflow(args: { + title: string; + userLoginPreferences?: UserLoginPreferences; + privateKeyUserNames?: Username[]; + showOperatesAs?: boolean; +}): Promise< + PasswordCredentials | Omit | undefined +>; +export async function runUserLoginWorkflow(args: { + title: string; + userLoginPreferences?: UserLoginPreferences; + privateKeyUserNames?: Username[]; + showOperatesAs?: boolean; +}): Promise< + PasswordCredentials | Omit | undefined +> { + const { + title, + userLoginPreferences, + privateKeyUserNames = [], + showOperatesAs, + } = args; + + const username = await promptForUsername( + title, + userLoginPreferences?.lastLogin + ); + let token: string | undefined; + let operateAs: OperateAsUsername | undefined; + + // Cancelled by user + if (username == null) { + return; + } + + const hasPrivateKey = privateKeyUserNames.includes(username); + + // Password + if (!hasPrivateKey) { + token = await promptForPassword(title); + + // Cancelled by user + if (token == null) { + return; + } + } + + // Operate As + if (showOperatesAs) { + const defaultValue = username as unknown as OperateAsUsername | undefined; + + const operateAs = await promptForOperateAs( + title, + userLoginPreferences?.operateAsUser[username] ?? defaultValue + ); + + // Cancelled by user + if (operateAs == null) { + return; + } + } + + if (hasPrivateKey) { + return { + type: 'keyPair', + username, + operateAs, + }; + } + + assertDefined(token, 'token'); + return { + type: 'password', + username, + token, + operateAs, + }; +} + /** * Create a status bar item for connecting to DH server */ @@ -270,3 +376,56 @@ export function updateConnectionStatusBarItem( const text = createConnectText(status, option); statusBarItem.text = text; } + +/** + * Prompt user for username. + * @param title Title of the prompt + * @param lastLogin Optional last login username + * @returns The username or undefined if cancelled by the user. + */ +export function promptForUsername( + title: string, + lastLogin?: Username +): Promise { + return vscode.window.showInputBox({ + ignoreFocusOut: true, + placeHolder: 'Username', + prompt: 'Deephaven username', + title, + value: lastLogin, + }) as Promise; +} + +/** + * Prompt the user for a password. + * @param title Title of the prompt + * @returns The password or undefined if cancelled by the user. + */ +export function promptForPassword(title: string): Promise { + return vscode.window.showInputBox({ + ignoreFocusOut: true, + placeHolder: 'Password', + prompt: 'Deephaven password', + password: true, + title, + }) as Promise; +} + +/** + * Prompt the user for an `Operate As` username. + * @param title Title of the prompt + * @param defaultValue Optional default value + * @returns The `Operate As` username or undefined if cancelled by the user. + */ +export function promptForOperateAs( + title: string, + defaultValue?: OperateAsUsername +): Promise { + return vscode.window.showInputBox({ + ignoreFocusOut: true, + placeHolder: 'Operate As', + prompt: 'Deephaven `Operate As` username', + title, + value: defaultValue, + }) as Promise; +} diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts