diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 57d628f170..5e0a1465c9 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -182,6 +182,7 @@ export type LocalizedMessages = { // Integration type labels integrationsPostgresTypeLabel: string; integrationsBigQueryTypeLabel: string; + integrationsSnowflakeTypeLabel: string; // PostgreSQL form strings integrationsPostgresNameLabel: string; integrationsPostgresNamePlaceholder: string; @@ -204,9 +205,36 @@ export type LocalizedMessages = { integrationsBigQueryCredentialsLabel: string; integrationsBigQueryCredentialsPlaceholder: string; integrationsBigQueryCredentialsRequired: string; + // Snowflake form strings + integrationsSnowflakeNameLabel: string; + integrationsSnowflakeNamePlaceholder: string; + integrationsSnowflakeAccountLabel: string; + integrationsSnowflakeAccountPlaceholder: string; + integrationsSnowflakeAuthMethodLabel: string; + integrationsSnowflakeAuthMethodSubLabel: string; + integrationsSnowflakeAuthMethodUsernamePassword: string; + integrationsSnowflakeAuthMethodKeyPair: string; + integrationsSnowflakeUnsupportedAuthMethod: string; + integrationsSnowflakeUsernameLabel: string; + integrationsSnowflakePasswordLabel: string; + integrationsSnowflakePasswordPlaceholder: string; + integrationsSnowflakeServiceAccountUsernameLabel: string; + integrationsSnowflakeServiceAccountUsernameHelp: string; + integrationsSnowflakePrivateKeyLabel: string; + integrationsSnowflakePrivateKeyHelp: string; + integrationsSnowflakePrivateKeyPlaceholder: string; + integrationsSnowflakePrivateKeyPassphraseLabel: string; + integrationsSnowflakePrivateKeyPassphraseHelp: string; + integrationsSnowflakeDatabaseLabel: string; + integrationsSnowflakeDatabasePlaceholder: string; + integrationsSnowflakeRoleLabel: string; + integrationsSnowflakeRolePlaceholder: string; + integrationsSnowflakeWarehouseLabel: string; + integrationsSnowflakeWarehousePlaceholder: string; // Common form strings integrationsRequiredField: string; integrationsOptionalField: string; + integrationsUnnamedIntegration: string; }; // Map all messages to specific payloads export class IInteractiveWindowMapping { diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 3c4b864dac..e9dd729c47 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -5,8 +5,15 @@ import { IExtensionContext } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; import { logger } from '../../../platform/logging'; import { IIntegrationDetector, IIntegrationManager, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; -import { IntegrationStatus, IntegrationWithStatus } from '../../../platform/notebooks/deepnote/integrationTypes'; +import { + DEEPNOTE_TO_INTEGRATION_TYPE, + IntegrationStatus, + IntegrationType, + IntegrationWithStatus, + RawIntegrationType +} from '../../../platform/notebooks/deepnote/integrationTypes'; import { BlockWithIntegration, scanBlocksForIntegrations } from './integrationUtils'; +import { IDeepnoteNotebookManager } from '../../types'; /** * Manages integration UI and commands for Deepnote notebooks @@ -21,7 +28,8 @@ export class IntegrationManager implements IIntegrationManager { @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(IIntegrationDetector) private readonly integrationDetector: IIntegrationDetector, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, - @inject(IIntegrationWebviewProvider) private readonly webviewProvider: IIntegrationWebviewProvider + @inject(IIntegrationWebviewProvider) private readonly webviewProvider: IIntegrationWebviewProvider, + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager ) {} public activate(): void { @@ -150,9 +158,33 @@ export class IntegrationManager implements IIntegrationManager { if (selectedIntegrationId && !integrations.has(selectedIntegrationId)) { logger.debug(`IntegrationManager: Adding requested integration ${selectedIntegrationId} to the map`); const config = await this.integrationStorage.getIntegrationConfig(selectedIntegrationId); + + // Try to get integration metadata from the project + const project = this.notebookManager.getOriginalProject(projectId); + const projectIntegration = project?.project.integrations?.find((i) => i.id === selectedIntegrationId); + + let integrationName: string | undefined; + let integrationType: IntegrationType | undefined; + + if (projectIntegration) { + integrationName = projectIntegration.name; + + // Validate that projectIntegration.type exists in the mapping before lookup + if (projectIntegration.type in DEEPNOTE_TO_INTEGRATION_TYPE) { + // Map the Deepnote integration type to our IntegrationType + integrationType = DEEPNOTE_TO_INTEGRATION_TYPE[projectIntegration.type as RawIntegrationType]; + } else { + logger.warn( + `IntegrationManager: Unknown integration type '${projectIntegration.type}' for integration ID '${selectedIntegrationId}' in project '${projectId}'. Integration type will be undefined.` + ); + } + } + integrations.set(selectedIntegrationId, { config: config || null, - status: config ? IntegrationStatus.Connected : IntegrationStatus.Disconnected + status: config ? IntegrationStatus.Connected : IntegrationStatus.Disconnected, + integrationName, + integrationType }); } diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 2f5059d787..1245ea60cb 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -131,6 +131,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { integrationsConfigureTitle: localize.Integrations.configureTitle, integrationsPostgresTypeLabel: localize.Integrations.postgresTypeLabel, integrationsBigQueryTypeLabel: localize.Integrations.bigQueryTypeLabel, + integrationsSnowflakeTypeLabel: localize.Integrations.snowflakeTypeLabel, integrationsCancel: localize.Integrations.cancel, integrationsSave: localize.Integrations.save, integrationsRequiredField: localize.Integrations.requiredField, @@ -154,7 +155,34 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { integrationsBigQueryProjectIdPlaceholder: localize.Integrations.bigQueryProjectIdPlaceholder, integrationsBigQueryCredentialsLabel: localize.Integrations.bigQueryCredentialsLabel, integrationsBigQueryCredentialsPlaceholder: localize.Integrations.bigQueryCredentialsPlaceholder, - integrationsBigQueryCredentialsRequired: localize.Integrations.bigQueryCredentialsRequired + integrationsBigQueryCredentialsRequired: localize.Integrations.bigQueryCredentialsRequired, + integrationsSnowflakeNameLabel: localize.Integrations.snowflakeNameLabel, + integrationsSnowflakeNamePlaceholder: localize.Integrations.snowflakeNamePlaceholder, + integrationsSnowflakeAccountLabel: localize.Integrations.snowflakeAccountLabel, + integrationsSnowflakeAccountPlaceholder: localize.Integrations.snowflakeAccountPlaceholder, + integrationsSnowflakeAuthMethodLabel: localize.Integrations.snowflakeAuthMethodLabel, + integrationsSnowflakeAuthMethodSubLabel: localize.Integrations.snowflakeAuthMethodSubLabel, + integrationsSnowflakeAuthMethodUsernamePassword: localize.Integrations.snowflakeAuthMethodUsernamePassword, + integrationsSnowflakeAuthMethodKeyPair: localize.Integrations.snowflakeAuthMethodKeyPair, + integrationsSnowflakeUnsupportedAuthMethod: localize.Integrations.snowflakeUnsupportedAuthMethod, + integrationsSnowflakeUsernameLabel: localize.Integrations.snowflakeUsernameLabel, + integrationsSnowflakePasswordLabel: localize.Integrations.snowflakePasswordLabel, + integrationsSnowflakePasswordPlaceholder: localize.Integrations.snowflakePasswordPlaceholder, + integrationsSnowflakeServiceAccountUsernameLabel: + localize.Integrations.snowflakeServiceAccountUsernameLabel, + integrationsSnowflakeServiceAccountUsernameHelp: localize.Integrations.snowflakeServiceAccountUsernameHelp, + integrationsSnowflakePrivateKeyLabel: localize.Integrations.snowflakePrivateKeyLabel, + integrationsSnowflakePrivateKeyHelp: localize.Integrations.snowflakePrivateKeyHelp, + integrationsSnowflakePrivateKeyPlaceholder: localize.Integrations.snowflakePrivateKeyPlaceholder, + integrationsSnowflakePrivateKeyPassphraseLabel: localize.Integrations.snowflakePrivateKeyPassphraseLabel, + integrationsSnowflakePrivateKeyPassphraseHelp: localize.Integrations.snowflakePrivateKeyPassphraseHelp, + integrationsSnowflakeDatabaseLabel: localize.Integrations.snowflakeDatabaseLabel, + integrationsSnowflakeDatabasePlaceholder: localize.Integrations.snowflakeDatabasePlaceholder, + integrationsSnowflakeRoleLabel: localize.Integrations.snowflakeRoleLabel, + integrationsSnowflakeRolePlaceholder: localize.Integrations.snowflakeRolePlaceholder, + integrationsSnowflakeWarehouseLabel: localize.Integrations.snowflakeWarehouseLabel, + integrationsSnowflakeWarehousePlaceholder: localize.Integrations.snowflakeWarehousePlaceholder, + integrationsUnnamedIntegration: localize.Integrations.unnamedIntegration('{0}') }; await this.currentPanel.webview.postMessage({ diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index 5ac805d243..b0ba8897ce 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -437,6 +437,8 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid return l10n.t('PostgreSQL'); case IntegrationType.BigQuery: return l10n.t('BigQuery'); + case IntegrationType.Snowflake: + return l10n.t('Snowflake'); default: return String(type); } diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 82d3aa0f78..5332eb9a63 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -830,10 +830,12 @@ export namespace Integrations { export const save = l10n.t('Save'); export const requiredField = l10n.t('*'); export const optionalField = l10n.t('(optional)'); + export const unnamedIntegration = (id: string) => l10n.t('Unnamed Integration ({0})', id); // Integration type labels export const postgresTypeLabel = l10n.t('PostgreSQL'); export const bigQueryTypeLabel = l10n.t('BigQuery'); + export const snowflakeTypeLabel = l10n.t('Snowflake'); // PostgreSQL form strings export const postgresNameLabel = l10n.t('Name (optional)'); @@ -849,7 +851,6 @@ export namespace Integrations { export const postgresPasswordLabel = l10n.t('Password'); export const postgresPasswordPlaceholder = l10n.t('••••••••'); export const postgresSslLabel = l10n.t('Use SSL'); - export const postgresUnnamedIntegration = (id: string) => l10n.t('Unnamed PostgreSQL Integration ({0})', id); // BigQuery form strings export const bigQueryNameLabel = l10n.t('Name (optional)'); @@ -860,7 +861,43 @@ export namespace Integrations { export const bigQueryCredentialsPlaceholder = l10n.t('{"type": "service_account", ...}'); export const bigQueryCredentialsRequired = l10n.t('Credentials are required'); export const bigQueryInvalidJson = (message: string) => l10n.t('Invalid JSON: {0}', message); - export const bigQueryUnnamedIntegration = (id: string) => l10n.t('Unnamed BigQuery Integration ({0})', id); + + // Snowflake form strings + export const snowflakeNameLabel = l10n.t('Name (optional)'); + export const snowflakeNamePlaceholder = l10n.t('My Snowflake Database'); + export const snowflakeAccountLabel = l10n.t('Account name'); + export const snowflakeAccountPlaceholder = l10n.t('ptb34938.us-east-1'); + export const snowflakeAuthMethodLabel = l10n.t('Authentication'); + export const snowflakeAuthMethodSubLabel = l10n.t('Method'); + export const snowflakeAuthMethodUsernamePassword = l10n.t('Username & password'); + export const snowflakeAuthMethodKeyPair = l10n.t('Key-pair (service account)'); + export const snowflakeUnsupportedAuthMethod = l10n.t( + 'This Snowflake integration uses an authentication method that is not supported in VS Code. You can view the integration details but cannot edit or use it.' + ); + export const snowflakeUsernameLabel = l10n.t('Username'); + export const snowflakeUsernamePlaceholder = l10n.t('user'); + export const snowflakePasswordLabel = l10n.t('Password'); + export const snowflakePasswordPlaceholder = l10n.t('••••••••'); + export const snowflakeServiceAccountUsernameLabel = l10n.t('Service Account Username'); + export const snowflakeServiceAccountUsernameHelp = l10n.t( + 'The username of the service account that will be used to connect to Snowflake' + ); + export const snowflakePrivateKeyLabel = l10n.t('Private Key'); + export const snowflakePrivateKeyHelp = l10n.t( + 'The private key in PEM format. Make sure to include the entire key, including BEGIN and END markers.' + ); + export const snowflakePrivateKeyPlaceholder = l10n.t("Begins with '-----BEGIN PRIVATE KEY-----'"); + export const snowflakePrivateKeyPassphraseLabel = l10n.t('Private Key Passphrase (optional)'); + export const snowflakePrivateKeyPassphraseHelp = l10n.t( + 'If the private key is encrypted, provide the passphrase to decrypt it.' + ); + export const snowflakePrivateKeyPassphrasePlaceholder = l10n.t(''); + export const snowflakeDatabaseLabel = l10n.t('Database (optional)'); + export const snowflakeDatabasePlaceholder = l10n.t(''); + export const snowflakeRoleLabel = l10n.t('Role (optional)'); + export const snowflakeRolePlaceholder = l10n.t(''); + export const snowflakeWarehouseLabel = l10n.t('Warehouse (optional)'); + export const snowflakeWarehousePlaceholder = l10n.t(''); } export namespace Deprecated { diff --git a/src/platform/errors/types.ts b/src/platform/errors/types.ts index 9c79583bfd..5a2bcfe9b2 100644 --- a/src/platform/errors/types.ts +++ b/src/platform/errors/types.ts @@ -105,7 +105,8 @@ export type ErrorCategory = | 'unknownProduct' | 'invalidInterpreter' | 'pythonAPINotInitialized' - | 'deepnoteserver'; + | 'deepnoteserver' + | 'unsupported_integration'; // If there are errors, then the are added to the telementry properties. export type TelemetryErrorProperties = { diff --git a/src/platform/errors/unsupportedIntegrationError.ts b/src/platform/errors/unsupportedIntegrationError.ts new file mode 100644 index 0000000000..9e39dddcad --- /dev/null +++ b/src/platform/errors/unsupportedIntegrationError.ts @@ -0,0 +1,18 @@ +import { BaseError } from './types'; + +/** + * Error thrown when an unsupported integration type is encountered. + * + * Cause: + * An integration configuration has a type that is not supported by the SQL integration system, + * or an integration uses an authentication method that is not supported in VSCode. + * + * Handled by: + * Callers should handle this error and inform the user that the integration type or + * authentication method is not supported. + */ +export class UnsupportedIntegrationError extends BaseError { + constructor(message: string) { + super('unsupported_integration', message); + } +} diff --git a/src/platform/notebooks/deepnote/integrationTypes.ts b/src/platform/notebooks/deepnote/integrationTypes.ts index ac1d782154..8c4442e8bc 100644 --- a/src/platform/notebooks/deepnote/integrationTypes.ts +++ b/src/platform/notebooks/deepnote/integrationTypes.ts @@ -9,7 +9,8 @@ export const DATAFRAME_SQL_INTEGRATION_ID = 'deepnote-dataframe-sql'; */ export enum IntegrationType { Postgres = 'postgres', - BigQuery = 'bigquery' + BigQuery = 'bigquery', + Snowflake = 'snowflake' } /** @@ -17,7 +18,8 @@ export enum IntegrationType { */ export const INTEGRATION_TYPE_TO_DEEPNOTE = { [IntegrationType.Postgres]: 'pgsql', - [IntegrationType.BigQuery]: 'big-query' + [IntegrationType.BigQuery]: 'big-query', + [IntegrationType.Snowflake]: 'snowflake' } as const satisfies { [type in IntegrationType]: string }; export type RawIntegrationType = (typeof INTEGRATION_TYPE_TO_DEEPNOTE)[keyof typeof INTEGRATION_TYPE_TO_DEEPNOTE]; @@ -27,7 +29,8 @@ export type RawIntegrationType = (typeof INTEGRATION_TYPE_TO_DEEPNOTE)[keyof typ */ export const DEEPNOTE_TO_INTEGRATION_TYPE: Record = { pgsql: IntegrationType.Postgres, - 'big-query': IntegrationType.BigQuery + 'big-query': IntegrationType.BigQuery, + snowflake: IntegrationType.Snowflake }; /** @@ -61,10 +64,62 @@ export interface BigQueryIntegrationConfig extends BaseIntegrationConfig { credentials: string; // JSON string of service account credentials } +// Import and re-export Snowflake auth constants from shared module +import { + type SnowflakeAuthMethod, + SnowflakeAuthMethods, + SUPPORTED_SNOWFLAKE_AUTH_METHODS, + isSupportedSnowflakeAuthMethod +} from './snowflakeAuthConstants'; +export { + type SnowflakeAuthMethod, + SnowflakeAuthMethods, + SUPPORTED_SNOWFLAKE_AUTH_METHODS, + isSupportedSnowflakeAuthMethod +}; + +/** + * Base Snowflake configuration with common fields + */ +interface BaseSnowflakeConfig extends BaseIntegrationConfig { + type: IntegrationType.Snowflake; + account: string; + warehouse?: string; + database?: string; + role?: string; +} + +/** + * Snowflake integration configuration (discriminated union) + */ +export type SnowflakeIntegrationConfig = BaseSnowflakeConfig & + ( + | { + authMethod: typeof SnowflakeAuthMethods.PASSWORD | null; + username: string; + password: string; + } + | { + authMethod: typeof SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR; + username: string; + privateKey: string; + privateKeyPassphrase?: string; + } + | { + // Unsupported auth methods - we store them but don't allow editing + authMethod: + | typeof SnowflakeAuthMethods.OKTA + | typeof SnowflakeAuthMethods.NATIVE_SNOWFLAKE + | typeof SnowflakeAuthMethods.AZURE_AD + | typeof SnowflakeAuthMethods.KEY_PAIR; + [key: string]: unknown; // Allow any additional fields for unsupported methods + } + ); + /** * Union type of all integration configurations */ -export type IntegrationConfig = PostgresIntegrationConfig | BigQueryIntegrationConfig; +export type IntegrationConfig = PostgresIntegrationConfig | BigQueryIntegrationConfig | SnowflakeIntegrationConfig; /** * Integration connection status diff --git a/src/platform/notebooks/deepnote/snowflakeAuthConstants.ts b/src/platform/notebooks/deepnote/snowflakeAuthConstants.ts new file mode 100644 index 0000000000..afc31e4553 --- /dev/null +++ b/src/platform/notebooks/deepnote/snowflakeAuthConstants.ts @@ -0,0 +1,33 @@ +/** + * Snowflake authentication methods + */ +export const SnowflakeAuthMethods = { + PASSWORD: 'PASSWORD', + OKTA: 'OKTA', + NATIVE_SNOWFLAKE: 'NATIVE_SNOWFLAKE', + AZURE_AD: 'AZURE_AD', + KEY_PAIR: 'KEY_PAIR', + SERVICE_ACCOUNT_KEY_PAIR: 'SERVICE_ACCOUNT_KEY_PAIR' +} as const; + +export type SnowflakeAuthMethod = (typeof SnowflakeAuthMethods)[keyof typeof SnowflakeAuthMethods]; + +/** + * Supported auth methods that we can configure in VSCode + */ +export const SUPPORTED_SNOWFLAKE_AUTH_METHODS = [ + null, // Legacy username+password (no authMethod field) + SnowflakeAuthMethods.PASSWORD, + SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR +] as const; + +export type SupportedSnowflakeAuthMethod = (typeof SUPPORTED_SNOWFLAKE_AUTH_METHODS)[number]; + +/** + * Type guard to check if a value is a supported Snowflake auth method + * @param value The value to check + * @returns true if the value is one of the supported auth methods + */ +export function isSupportedSnowflakeAuthMethod(value: unknown): value is SupportedSnowflakeAuthMethod { + return (SUPPORTED_SNOWFLAKE_AUTH_METHODS as readonly unknown[]).includes(value); +} diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts index 9a5aa93b93..f425981270 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts @@ -1,27 +1,17 @@ import { inject, injectable } from 'inversify'; -import { CancellationToken, Event, EventEmitter, NotebookDocument, workspace } from 'vscode'; +import { CancellationToken, Event, EventEmitter, l10n, NotebookDocument, workspace } from 'vscode'; import { IDisposableRegistry, Resource } from '../../common/types'; import { EnvironmentVariables } from '../../common/variables/types'; -import { BaseError } from '../../errors/types'; +import { UnsupportedIntegrationError } from '../../errors/unsupportedIntegrationError'; import { logger } from '../../logging'; import { IIntegrationStorage, ISqlIntegrationEnvVarsProvider } from './types'; -import { DATAFRAME_SQL_INTEGRATION_ID, IntegrationConfig, IntegrationType } from './integrationTypes'; - -/** - * Error thrown when an unsupported integration type is encountered. - * - * Cause: - * An integration configuration has a type that is not supported by the SQL integration system. - * - * Handled by: - * Callers should handle this error and inform the user that the integration type is not supported. - */ -class UnsupportedIntegrationError extends BaseError { - constructor(public readonly integrationType: string) { - super('unknown', `Unsupported integration type: ${integrationType}`); - } -} +import { + DATAFRAME_SQL_INTEGRATION_ID, + IntegrationConfig, + IntegrationType, + SnowflakeAuthMethods +} from './integrationTypes'; /** * Converts an integration ID to the environment variable name format expected by SQL blocks. @@ -73,8 +63,86 @@ function convertIntegrationConfigToJson(config: IntegrationConfig): string { }); } + case IntegrationType.Snowflake: { + // Build Snowflake connection URL + // Format depends on auth method: + // Username+password: snowflake://{username}:{password}@{account}/{database}?warehouse={warehouse}&role={role}&application=YourApp + // Service account key-pair: snowflake://{username}@{account}/{database}?warehouse={warehouse}&role={role}&authenticator=snowflake_jwt&application=YourApp + const encodedAccount = encodeURIComponent(config.account); + + let url: string; + const params: Record = {}; + + if (config.authMethod === null || config.authMethod === SnowflakeAuthMethods.PASSWORD) { + // Username+password authentication + const encodedUsername = encodeURIComponent(config.username); + const encodedPassword = encodeURIComponent(config.password); + const database = config.database ? `/${encodeURIComponent(config.database)}` : ''; + url = `snowflake://${encodedUsername}:${encodedPassword}@${encodedAccount}${database}`; + + const queryParams = new URLSearchParams(); + if (config.warehouse) { + queryParams.set('warehouse', config.warehouse); + } + if (config.role) { + queryParams.set('role', config.role); + } + queryParams.set('application', 'Deepnote'); + + const queryString = queryParams.toString(); + if (queryString) { + url += `?${queryString}`; + } + } else { + // Service account key-pair authentication (the only other supported method) + // TypeScript needs help narrowing the type here + if (config.authMethod !== SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR) { + // This should never happen due to the type guard above, but TypeScript needs this + throw new UnsupportedIntegrationError( + l10n.t( + "Snowflake integration with auth method '{0}' is not supported in VSCode", + config.authMethod + ) + ); + } + + const encodedUsername = encodeURIComponent(config.username); + const database = config.database ? `/${encodeURIComponent(config.database)}` : ''; + url = `snowflake://${encodedUsername}@${encodedAccount}${database}`; + + const queryParams = new URLSearchParams(); + if (config.warehouse) { + queryParams.set('warehouse', config.warehouse); + } + if (config.role) { + queryParams.set('role', config.role); + } + queryParams.set('authenticator', 'snowflake_jwt'); + queryParams.set('application', 'Deepnote'); + + const queryString = queryParams.toString(); + if (queryString) { + url += `?${queryString}`; + } + + // For key-pair auth, pass the private key and passphrase as params + params.snowflake_private_key = btoa(config.privateKey); + if (config.privateKeyPassphrase) { + params.snowflake_private_key_passphrase = config.privateKeyPassphrase; + } + } + + return JSON.stringify({ + url: url, + params: params, + param_style: 'pyformat' + }); + } + default: - throw new UnsupportedIntegrationError((config as IntegrationConfig).type); + throw new UnsupportedIntegrationError( + l10n.t('Unsupported integration type: {0}', (config as IntegrationConfig).type) + ); } } diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts index 82cf82c20e..5a15e4f30b 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts @@ -5,7 +5,13 @@ import { CancellationTokenSource, EventEmitter, NotebookCell, NotebookCellKind, import { IDisposableRegistry } from '../../common/types'; import { IntegrationStorage } from './integrationStorage'; import { SqlIntegrationEnvironmentVariablesProvider } from './sqlIntegrationEnvironmentVariablesProvider'; -import { IntegrationType, PostgresIntegrationConfig, BigQueryIntegrationConfig } from './integrationTypes'; +import { + IntegrationType, + PostgresIntegrationConfig, + BigQueryIntegrationConfig, + SnowflakeIntegrationConfig, + SnowflakeAuthMethods +} from './integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; suite('SqlIntegrationEnvironmentVariablesProvider', () => { @@ -372,6 +378,302 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { const envVars = await provider.getEnvironmentVariables(uri, cts.token); assert.deepStrictEqual(envVars, {}); }); + + suite('Snowflake Integration', () => { + test('Returns environment variable for Snowflake with PASSWORD auth', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'my-snowflake'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'My Snowflake', + type: IntegrationType.Snowflake, + account: 'myorg-myaccount', + warehouse: 'COMPUTE_WH', + database: 'MYDB', + role: 'ANALYST', + authMethod: SnowflakeAuthMethods.PASSWORD, + username: 'john.doe', + password: 'secret123' + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT * FROM customers', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + + assert.property(envVars, 'SQL_MY_SNOWFLAKE'); + const credentialsJson = JSON.parse(envVars['SQL_MY_SNOWFLAKE']!); + assert.strictEqual( + credentialsJson.url, + 'snowflake://john.doe:secret123@myorg-myaccount/MYDB?warehouse=COMPUTE_WH&role=ANALYST&application=Deepnote' + ); + assert.deepStrictEqual(credentialsJson.params, {}); + assert.strictEqual(credentialsJson.param_style, 'pyformat'); + }); + + test('Returns environment variable for Snowflake with legacy null auth (username+password)', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'legacy-snowflake'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Legacy Snowflake', + type: IntegrationType.Snowflake, + account: 'legacy-account', + warehouse: 'WH', + database: 'DB', + authMethod: null, + username: 'user', + password: 'pass' + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + + assert.property(envVars, 'SQL_LEGACY_SNOWFLAKE'); + const credentialsJson = JSON.parse(envVars['SQL_LEGACY_SNOWFLAKE']!); + assert.strictEqual( + credentialsJson.url, + 'snowflake://user:pass@legacy-account/DB?warehouse=WH&application=Deepnote' + ); + assert.deepStrictEqual(credentialsJson.params, {}); + }); + + test('Returns environment variable for Snowflake with SERVICE_ACCOUNT_KEY_PAIR auth', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-keypair'; + const privateKey = + '-----BEGIN ' + 'PRIVATE KEY-----\nfakekey-MIIEvQIBADANBg...\n-----END ' + 'PRIVATE KEY-----'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake KeyPair', + type: IntegrationType.Snowflake, + account: 'keypair-account', + warehouse: 'ETL_WH', + database: 'PROD_DB', + role: 'ETL_ROLE', + authMethod: SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR, + username: 'service_account', + privateKey: privateKey, + privateKeyPassphrase: 'passphrase123' + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT * FROM events', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + + assert.property(envVars, 'SQL_SNOWFLAKE_KEYPAIR'); + const credentialsJson = JSON.parse(envVars['SQL_SNOWFLAKE_KEYPAIR']!); + assert.strictEqual( + credentialsJson.url, + 'snowflake://service_account@keypair-account/PROD_DB?warehouse=ETL_WH&role=ETL_ROLE&authenticator=snowflake_jwt&application=Deepnote' + ); + assert.deepStrictEqual(credentialsJson.params, { + snowflake_private_key: Buffer.from(privateKey).toString('base64'), + snowflake_private_key_passphrase: 'passphrase123' + }); + assert.strictEqual(credentialsJson.param_style, 'pyformat'); + }); + + test('Returns environment variable for Snowflake with SERVICE_ACCOUNT_KEY_PAIR auth without passphrase', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-keypair-no-pass'; + const privateKey = + '-----BEGIN ' + 'PRIVATE KEY-----\nfakekey-MIIEvQIBADANBg...\n-----END ' + 'PRIVATE KEY-----'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake KeyPair No Pass', + type: IntegrationType.Snowflake, + account: 'account123', + warehouse: 'WH', + database: 'DB', + authMethod: SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR, + username: 'svc_user', + privateKey: privateKey + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + + assert.property(envVars, 'SQL_SNOWFLAKE_KEYPAIR_NO_PASS'); + const credentialsJson = JSON.parse(envVars['SQL_SNOWFLAKE_KEYPAIR_NO_PASS']!); + assert.strictEqual( + credentialsJson.url, + 'snowflake://svc_user@account123/DB?warehouse=WH&authenticator=snowflake_jwt&application=Deepnote' + ); + assert.deepStrictEqual(credentialsJson.params, { + snowflake_private_key: Buffer.from(privateKey).toString('base64') + }); + }); + + test('Properly encodes special characters in Snowflake credentials', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-special'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake Special', + type: IntegrationType.Snowflake, + account: 'my-org.account', + warehouse: 'WH@2024', + database: 'DB:TEST', + role: 'ROLE#1', + authMethod: SnowflakeAuthMethods.PASSWORD, + username: 'user@domain.com', + password: 'p@ss:word!#$%' + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + + assert.property(envVars, 'SQL_SNOWFLAKE_SPECIAL'); + const credentialsJson = JSON.parse(envVars['SQL_SNOWFLAKE_SPECIAL']!); + // Verify URL encoding of special characters + assert.strictEqual( + credentialsJson.url, + 'snowflake://user%40domain.com:p%40ss%3Aword!%23%24%25@my-org.account/DB%3ATEST?warehouse=WH%402024&role=ROLE%231&application=Deepnote' + ); + }); + + test('Handles Snowflake with minimal optional fields', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-minimal'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake Minimal', + type: IntegrationType.Snowflake, + account: 'minimal-account', + authMethod: SnowflakeAuthMethods.PASSWORD, + username: 'user', + password: 'pass' + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + + assert.property(envVars, 'SQL_SNOWFLAKE_MINIMAL'); + const credentialsJson = JSON.parse(envVars['SQL_SNOWFLAKE_MINIMAL']!); + // Should not include warehouse, database, or role in URL when not provided + assert.strictEqual(credentialsJson.url, 'snowflake://user:pass@minimal-account?application=Deepnote'); + assert.strictEqual(credentialsJson.param_style, 'pyformat'); + }); + + test('Skips unsupported Snowflake auth method (OKTA)', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-okta'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake OKTA', + type: IntegrationType.Snowflake, + account: 'okta-account', + authMethod: SnowflakeAuthMethods.OKTA + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + // Should return empty object when unsupported auth method is encountered + const envVars = await provider.getEnvironmentVariables(uri); + assert.deepStrictEqual(envVars, {}); + }); + + test('Skips unsupported Snowflake auth method (AZURE_AD)', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-azure'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake Azure', + type: IntegrationType.Snowflake, + account: 'azure-account', + authMethod: SnowflakeAuthMethods.AZURE_AD + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + assert.deepStrictEqual(envVars, {}); + }); + + test('Skips unsupported Snowflake auth method (KEY_PAIR)', async () => { + const uri = Uri.file('/test/notebook.deepnote'); + const integrationId = 'snowflake-keypair-user'; + const config: SnowflakeIntegrationConfig = { + id: integrationId, + name: 'Snowflake KeyPair User', + type: IntegrationType.Snowflake, + account: 'keypair-user-account', + authMethod: SnowflakeAuthMethods.KEY_PAIR + }; + + const notebook = createMockNotebook(uri, [ + createMockCell(0, NotebookCellKind.Code, 'sql', 'SELECT 1', { + sql_integration_id: integrationId + }) + ]); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(integrationStorage.getIntegrationConfig(integrationId)).thenResolve(config); + + const envVars = await provider.getEnvironmentVariables(uri); + assert.deepStrictEqual(envVars, {}); + }); + }); }); function createMockNotebook(uri: Uri, cells: NotebookCell[]): NotebookDocument { diff --git a/src/webviews/webview-side/integrations/BigQueryForm.tsx b/src/webviews/webview-side/integrations/BigQueryForm.tsx index ee3b4513f5..60710ca912 100644 --- a/src/webviews/webview-side/integrations/BigQueryForm.tsx +++ b/src/webviews/webview-side/integrations/BigQueryForm.tsx @@ -71,11 +71,11 @@ export const BigQueryForm: React.FC = ({ return; } - const unnamedIntegration = format('Unnamed BigQuery Integration ({0})', integrationId); + const unnamedIntegration = getLocString('integrationsUnnamedIntegration', 'Unnamed Integration ({0})'); const config: BigQueryIntegrationConfig = { id: integrationId, - name: (name || unnamedIntegration).trim(), + name: (name || format(unnamedIntegration, integrationId)).trim(), type: 'bigquery', projectId: projectId.trim(), credentials: trimmedCredentials diff --git a/src/webviews/webview-side/integrations/ConfigurationForm.tsx b/src/webviews/webview-side/integrations/ConfigurationForm.tsx index ca6ea2e36d..d42ab144a8 100644 --- a/src/webviews/webview-side/integrations/ConfigurationForm.tsx +++ b/src/webviews/webview-side/integrations/ConfigurationForm.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { getLocString } from '../react-common/locReactSide'; import { PostgresForm } from './PostgresForm'; import { BigQueryForm } from './BigQueryForm'; +import { SnowflakeForm } from './SnowflakeForm'; import { IntegrationConfig, IntegrationType } from './types'; export interface IConfigurationFormProps { @@ -22,7 +23,7 @@ export const ConfigurationForm: React.FC = ({ onCancel }) => { // Determine integration type from existing config, integration metadata from project, or ID - const getIntegrationType = (): 'postgres' | 'bigquery' => { + const getIntegrationType = (): 'postgres' | 'bigquery' | 'snowflake' => { if (existingConfig) { return existingConfig.type; } @@ -37,6 +38,9 @@ export const ConfigurationForm: React.FC = ({ if (integrationId.includes('bigquery')) { return 'bigquery'; } + if (integrationId.includes('snowflake')) { + return 'snowflake'; + } // Default to postgres return 'postgres'; }; @@ -67,7 +71,7 @@ export const ConfigurationForm: React.FC = ({ onSave={onSave} onCancel={onCancel} /> - ) : ( + ) : selectedIntegrationType === 'bigquery' ? ( = ({ onSave={onSave} onCancel={onCancel} /> + ) : ( + )} diff --git a/src/webviews/webview-side/integrations/IntegrationItem.tsx b/src/webviews/webview-side/integrations/IntegrationItem.tsx index f1472ceb38..73b97fd99e 100644 --- a/src/webviews/webview-side/integrations/IntegrationItem.tsx +++ b/src/webviews/webview-side/integrations/IntegrationItem.tsx @@ -14,6 +14,8 @@ const getIntegrationTypeLabel = (type: IntegrationType): string => { return getLocString('integrationsPostgresTypeLabel', 'PostgreSQL'); case 'bigquery': return getLocString('integrationsBigQueryTypeLabel', 'BigQuery'); + case 'snowflake': + return getLocString('integrationsSnowflakeTypeLabel', 'Snowflake'); default: return type; } diff --git a/src/webviews/webview-side/integrations/PostgresForm.tsx b/src/webviews/webview-side/integrations/PostgresForm.tsx index d967baae7b..588ab33f74 100644 --- a/src/webviews/webview-side/integrations/PostgresForm.tsx +++ b/src/webviews/webview-side/integrations/PostgresForm.tsx @@ -49,11 +49,11 @@ export const PostgresForm: React.FC = ({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - const unnamedIntegration = format('Unnamed PostgreSQL Integration ({0})', integrationId); + const unnamedIntegration = getLocString('integrationsUnnamedIntegration', 'Unnamed Integration ({0})'); const config: PostgresIntegrationConfig = { id: integrationId, - name: (name || unnamedIntegration).trim(), + name: (name || format(unnamedIntegration, integrationId)).trim(), type: 'postgres', host, port: parseInt(port, 10), diff --git a/src/webviews/webview-side/integrations/SnowflakeForm.tsx b/src/webviews/webview-side/integrations/SnowflakeForm.tsx new file mode 100644 index 0000000000..6aaf4c5cca --- /dev/null +++ b/src/webviews/webview-side/integrations/SnowflakeForm.tsx @@ -0,0 +1,386 @@ +import * as React from 'react'; +import { format, getLocString } from '../react-common/locReactSide'; +import { + SnowflakeIntegrationConfig, + SnowflakeAuthMethod, + SnowflakeAuthMethods, + isSupportedSnowflakeAuthMethod +} from './types'; + +export interface ISnowflakeFormProps { + integrationId: string; + existingConfig: SnowflakeIntegrationConfig | null; + integrationName?: string; + onSave: (config: SnowflakeIntegrationConfig) => void; + onCancel: () => void; +} + +// Helper to get initial values from existing config +function getInitialValues(existingConfig: SnowflakeIntegrationConfig | null) { + if (!existingConfig) { + return { + username: '', + password: '', + privateKey: '', + privateKeyPassphrase: '' + }; + } + + // Type narrowing based on authMethod + // Note: existingConfig can have authMethod === null (legacy configs from backend) + if (existingConfig.authMethod === null || existingConfig.authMethod === SnowflakeAuthMethods.PASSWORD) { + return { + username: existingConfig.username || '', + password: existingConfig.password || '', + privateKey: '', + privateKeyPassphrase: '' + }; + } else if (existingConfig.authMethod === SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR) { + return { + username: existingConfig.username || '', + password: '', + privateKey: existingConfig.privateKey || '', + privateKeyPassphrase: existingConfig.privateKeyPassphrase || '' + }; + } else { + // Unsupported auth method - try to extract username if available + return { + username: 'username' in existingConfig ? String(existingConfig.username || '') : '', + password: '', + privateKey: '', + privateKeyPassphrase: '' + }; + } +} + +export const SnowflakeForm: React.FC = ({ + integrationId, + existingConfig, + integrationName, + onSave, + onCancel +}) => { + const isUnsupported = existingConfig ? !isSupportedSnowflakeAuthMethod(existingConfig.authMethod) : false; + const initialValues = getInitialValues(existingConfig); + + const [name, setName] = React.useState(existingConfig?.name || integrationName || ''); + const [account, setAccount] = React.useState(existingConfig?.account || ''); + const [authMethod, setAuthMethod] = React.useState( + existingConfig?.authMethod ?? SnowflakeAuthMethods.PASSWORD + ); + const [username, setUsername] = React.useState(initialValues.username); + const [password, setPassword] = React.useState(initialValues.password); + const [privateKey, setPrivateKey] = React.useState(initialValues.privateKey); + const [privateKeyPassphrase, setPrivateKeyPassphrase] = React.useState(initialValues.privateKeyPassphrase); + const [database, setDatabase] = React.useState(existingConfig?.database || ''); + const [warehouse, setWarehouse] = React.useState(existingConfig?.warehouse || ''); + const [role, setRole] = React.useState(existingConfig?.role || ''); + + // Update form fields when existingConfig or integrationName changes + React.useEffect(() => { + if (existingConfig) { + const values = getInitialValues(existingConfig); + setName(existingConfig.name || ''); + setAccount(existingConfig.account || ''); + setAuthMethod(existingConfig.authMethod ?? SnowflakeAuthMethods.PASSWORD); + setUsername(values.username); + setPassword(values.password); + setPrivateKey(values.privateKey); + setPrivateKeyPassphrase(values.privateKeyPassphrase); + setDatabase(existingConfig.database || ''); + setWarehouse(existingConfig.warehouse || ''); + setRole(existingConfig.role || ''); + } else { + setName(integrationName || ''); + setAccount(''); + setAuthMethod(SnowflakeAuthMethods.PASSWORD); + setUsername(''); + setPassword(''); + setPrivateKey(''); + setPrivateKeyPassphrase(''); + setDatabase(''); + setWarehouse(''); + setRole(''); + } + }, [existingConfig, integrationName]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const unnamedIntegration = getLocString('integrationsUnnamedIntegration', 'Unnamed Integration ({0})'); + + let config: SnowflakeIntegrationConfig; + + if (authMethod === SnowflakeAuthMethods.PASSWORD) { + config = { + id: integrationId, + name: (name || format(unnamedIntegration, integrationId)).trim(), + type: 'snowflake', + account: account.trim(), + authMethod: authMethod, + username: username.trim(), + password: password.trim(), + database: database.trim() || undefined, + warehouse: warehouse.trim() || undefined, + role: role.trim() || undefined + }; + } else if (authMethod === SnowflakeAuthMethods.SERVICE_ACCOUNT_KEY_PAIR) { + // Guard against empty private key + if (!privateKey.trim()) { + return; + } + + config = { + id: integrationId, + name: (name || format(unnamedIntegration, integrationId)).trim(), + type: 'snowflake', + account: account.trim(), + authMethod: authMethod, + username: username.trim(), + privateKey: privateKey.trim(), + privateKeyPassphrase: privateKeyPassphrase.trim() || undefined, + database: database.trim() || undefined, + warehouse: warehouse.trim() || undefined, + role: role.trim() || undefined + }; + } else { + // This shouldn't happen as we disable the form for unsupported methods + return; + } + + onSave(config); + }; + + return ( +
+ {isUnsupported && ( +
+ {getLocString( + 'integrationsSnowflakeUnsupportedAuthMethod', + 'This Snowflake integration uses an authentication method that is not supported in VS Code. You can view the integration details but cannot edit or use it.' + )} +
+ )} + +
+ + setName(e.target.value)} + placeholder={getLocString('integrationsSnowflakeNamePlaceholder', '')} + autoComplete="off" + disabled={isUnsupported} + /> +
+ +
+ + setAccount(e.target.value)} + placeholder={getLocString('integrationsSnowflakeAccountPlaceholder', 'abcd.us-east-1')} + autoComplete="off" + required + pattern=".*\S.*" + disabled={isUnsupported} + /> +
+ +
+ + +
+ + {!isUnsupported && + (authMethod === SnowflakeAuthMethods.PASSWORD ? ( + <> +
+ + setUsername(e.target.value)} + autoComplete="username" + required + pattern=".*\S.*" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder={getLocString('integrationsSnowflakePasswordPlaceholder', '••••••••')} + autoComplete="current-password" + required + pattern=".*\S.*" + /> +
+ + ) : ( + <> +
+ +

+ {getLocString( + 'integrationsSnowflakeServiceAccountUsernameHelp', + 'The username of the service account that will be used to connect to Snowflake' + )} +

+ setUsername(e.target.value)} + autoComplete="username" + required + pattern=".*\S.*" + aria-describedby="username-help" + /> +
+ +
+ +

+ {getLocString( + 'integrationsSnowflakePrivateKeyHelp', + 'The private key in PEM format. Make sure to include the entire key, including BEGIN and END markers.' + )} +

+