From f87b5d15681adc049115054ef5e40afdf5126d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=B6=85?= Date: Thu, 24 Sep 2026 18:25:23 +0800 Subject: [PATCH] feat(salesforce): add Salesforce SOQL data source --- .../public/icons/database/salesforce.svg | 3 + .../connection/ConnectionDialog.vue | 728 ++++++- ...ctionDialog.salesforcePasswordMode.spec.ts | 266 +++ .../src/components/diff/DataCompareDialog.vue | 2 +- .../components/diff/SchemaDiffConfigStep.vue | 2 +- .../editor/EditorSettingsDialog.vue | 44 +- .../src/components/editor/QueryEditor.vue | 4 +- .../editor/useQueryEditorBatchSelection.ts | 3 +- .../editor/useQueryEditorCompletion.ts | 68 +- .../editor/useQueryEditorDiagnostics.ts | 4 +- .../export/DatabaseExportDialog.vue | 2 +- apps/desktop/src/components/grid/DataGrid.vue | 76 +- .../src/components/icons/DatabaseIcon.vue | 1 + .../src/components/layout/EditorToolbar.vue | 44 +- .../settings/McpDatabaseScopePicker.vue | 1 + .../settings/McpResourceScopePicker.vue | 32 +- .../__tests__/McpDatabaseScopePicker.spec.ts | 1 + .../__tests__/McpResourceScopePicker.spec.ts | 70 +- .../sidebar/SidebarTreeRuntimeHost.vue | 2 +- .../sql-file/SqlFileExecutionDialog.vue | 2 +- .../__tests__/useDataGridEditor.spec.ts | 61 +- .../useSalesforceSaveConfirmation.spec.ts | 85 + .../src/composables/useDataGridEditor.ts | 44 + .../src/composables/useNavigationTargets.ts | 4 +- apps/desktop/src/composables/useQuickOpen.ts | 2 +- .../useSalesforceSaveConfirmation.ts | 76 + .../composables/useSidebarDataOpenRuntime.ts | 4 +- .../src/composables/useSqlExecution.ts | 2 +- .../__tests__/salesforceIdentityCopy.spec.ts | 80 + apps/desktop/src/i18n/locales/az.ts | 7 + apps/desktop/src/i18n/locales/en.ts | 81 + apps/desktop/src/i18n/locales/es.ts | 82 + apps/desktop/src/i18n/locales/it.ts | 7 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 7 + apps/desktop/src/i18n/locales/tr.ts | 7 + apps/desktop/src/i18n/locales/zh-CN.ts | 81 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../__tests__/mcp/mcpPolicySelection.spec.ts | 13 + .../lib/__tests__/table/tableEditing.spec.ts | 61 + .../__tests__/table/tableSelectSql.spec.ts | 24 +- apps/desktop/src/lib/backend/api.ts | 6 + apps/desktop/src/lib/backend/http.ts | 34 + apps/desktop/src/lib/backend/tauri.ts | 32 + .../lib/connection/connectionPresentation.ts | 4 + .../src/lib/connection/connectionUrl.ts | 2 + .../lib/connection/connectionUrlBuilder.ts | 2 +- .../connection/connectionVisibleDatabases.ts | 2 +- .../lib/dataGrid/dataGridColumnNameCopy.ts | 2 +- .../src/lib/dataGrid/dataGridSaveUi.ts | 2 +- .../lib/database/databaseFeatureSupport.ts | 6 +- .../lib/database/databaseNamespaceCreation.ts | 1 + .../database/databaseObjectCapabilities.ts | 1 + .../lib/database/databasePropertyEditing.ts | 1 + .../database/databaseTableDataCapabilities.ts | 19 + .../database/sqlExecutionTargetRegistry.ts | 1 + .../lib/editor/codemirrorInsertValueHints.ts | 2 +- .../src/lib/editor/codemirrorSqlDialect.ts | 142 +- .../desktop/src/lib/mcp/mcpPolicySelection.ts | 3 + .../src/lib/sidebar/sidebarNodeOrdering.ts | 13 +- .../lib/soql/__tests__/soqlCompletion.spec.ts | 319 +++ apps/desktop/src/lib/soql/soqlCompletion.ts | 541 ++++++ apps/desktop/src/lib/sql/semantic/dialect.ts | 20 +- .../desktop/src/lib/sql/sqlStatementRanges.ts | 2 +- .../tableMetadataCapabilities.spec.ts | 15 + apps/desktop/src/lib/table/tableEditing.ts | 46 + .../lib/table/tableMetadataCapabilities.ts | 9 + apps/desktop/src/lib/table/tableSelectSql.ts | 19 + ...connectionStore.salesforceIdentity.spec.ts | 131 ++ .../stores/__tests__/settingsStore.spec.ts | 11 + apps/desktop/src/stores/connectionStore.ts | 119 +- apps/desktop/src/stores/settingsStore.ts | 10 + .../src/types/generated/connectionProfiles.ts | 3 + .../src/types/generated/databaseTypes.ts | 1 + apps/desktop/src/types/salesforce.ts | 80 + crates/dbx-ai-provider/src/ai_cli_agent.rs | 8 + .../assets/database-drivers.manifest.json | 33 + crates/dbx-core/src/ai/mcp_policy.rs | 43 +- .../src/connection/connection_secrets.rs | 98 + crates/dbx-core/src/connection/mod.rs | 117 ++ crates/dbx-core/src/data/csv_export.rs | 66 + crates/dbx-core/src/lib.rs | 1 + .../dbx-core/src/persistence/secret_codec.rs | 172 +- crates/dbx-core/src/persistence/storage.rs | 122 +- crates/dbx-core/src/query/mod.rs | 19 + crates/dbx-core/src/schema/mod.rs | 13 + .../dbx-core/tests/database_capabilities.rs | 12 + crates/dbx-drivers/src/db/mod.rs | 1 + .../dbx-drivers/src/db/salesforce_driver.rs | 1722 +++++++++++++++++ crates/dbx-drivers/src/lib.rs | 1 + crates/dbx-drivers/src/salesforce_oauth.rs | 1122 +++++++++++ crates/dbx-mcp/src/backend.rs | 68 +- crates/dbx-mcp/src/server.rs | 1135 ++++++++++- crates/dbx-mcp/tests/mongodb_databases.rs | 1 + crates/dbx-mcp/tests/protocol.rs | 7 +- .../dbx-sql/src/data_grid_salesforce_sql.rs | 459 +++++ crates/dbx-sql/src/data_grid_sql.rs | 16 + crates/dbx-sql/src/query_execution_sql.rs | 170 ++ crates/dbx-sql/src/query_result_sql.rs | 10 +- crates/dbx-sql/src/sql_dialect/ddl_profile.rs | 2 +- crates/dbx-sql/src/sql_dialect/descriptor.rs | 36 +- .../dbx-sql/src/sql_dialect/dialect_types.rs | 1 + .../dbx-sql/src/sql_dialect/dialect_yaml.rs | 2 + crates/dbx-sql/src/sql_dialect/identifiers.rs | 4 + ...scriptor_snapshots__all_dialects_info.snap | 20 + .../dbx-sql/src/sql_dialect/table_select.rs | 126 ++ crates/dbx-sql/src/sql_dialect/tests.rs | 7 + crates/dbx-sql/src/sql_risk.rs | 97 + crates/dbx-types/src/models/connection.rs | 15 + crates/dbx-web/src/main.rs | 9 + crates/dbx-web/src/routes/connection.rs | 87 +- crates/dbx-web/src/routes/mcp_policy.rs | 1 + docs/content/docs/mcp.cn.mdx | 28 + docs/content/docs/mcp.mdx | 28 + docs/salesforce-soql-integration-spec.md | 302 +++ packages/mcp-server/README.md | 10 + .../connection-types/profiles/catalog.yaml | 7 + plugins/connection-types/salesforce.yaml | 32 + plugins/dialects/soql.yaml | 85 + src-tauri/src/commands/connection.rs | 21 + src-tauri/src/commands/mcp_bridge.rs | 2 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/salesforce_oauth.rs | 62 + src-tauri/src/lib.rs | 13 + 125 files changed, 9999 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/public/icons/database/salesforce.svg create mode 100644 apps/desktop/src/components/connection/__tests__/ConnectionDialog.salesforcePasswordMode.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useSalesforceSaveConfirmation.spec.ts create mode 100644 apps/desktop/src/composables/useSalesforceSaveConfirmation.ts create mode 100644 apps/desktop/src/i18n/__tests__/salesforceIdentityCopy.spec.ts create mode 100644 apps/desktop/src/lib/soql/__tests__/soqlCompletion.spec.ts create mode 100644 apps/desktop/src/lib/soql/soqlCompletion.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.salesforceIdentity.spec.ts create mode 100644 apps/desktop/src/types/salesforce.ts create mode 100644 crates/dbx-drivers/src/db/salesforce_driver.rs create mode 100644 crates/dbx-drivers/src/salesforce_oauth.rs create mode 100644 crates/dbx-sql/src/data_grid_salesforce_sql.rs create mode 100644 docs/salesforce-soql-integration-spec.md create mode 100644 plugins/connection-types/salesforce.yaml create mode 100644 plugins/dialects/soql.yaml create mode 100644 src-tauri/src/commands/salesforce_oauth.rs diff --git a/apps/desktop/public/icons/database/salesforce.svg b/apps/desktop/public/icons/database/salesforce.svg new file mode 100644 index 0000000000..7281c0b676 --- /dev/null +++ b/apps/desktop/public/icons/database/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index fb5874c614..d027c013c5 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -36,6 +36,8 @@ import type { import { CONNECTION_PICKER_OPTIONS, CONNECTION_PROFILES, CONNECTION_PROFILE_ICONS, type ConnectionPickerOption, type ConnectionProfileCategory, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles"; import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb"; import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics"; +import type { SalesforceAuthContext, SalesforceAuthMode, SalesforceEnvironment, SalesforceExternalConfig, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult } from "@/types/salesforce"; +import { SALESFORCE_OAUTH_CALLBACK_URL } from "@/types/salesforce"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; import type { MqttConnectionConfig } from "@/types/mqtt"; import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos"; @@ -1574,6 +1576,390 @@ function buildVictoriaMetricsExternalConfig(): VictoriaMetricsExternalConfig { return { apiPath, lookback }; } +// --------------------------------------------------------------------------- +// Salesforce OAuth / device-code flow state +// --------------------------------------------------------------------------- + +const salesforceAuthMode = ref("token"); +const salesforceEnvironment = ref("production"); +const salesforceLoginUrl = ref(""); +const salesforceClientId = ref(""); +const salesforceClientSecret = ref(""); +// Username-password (ROPC) mode credentials. The password is never +// round-tripped from the backend; blank on edit unless re-typed. +const salesforceUsername = ref(""); +const salesforceUserPassword = ref(""); + +const salesforceOauthRunning = ref(false); +const salesforceOauthError = ref(""); +const salesforceOauthSuccess = ref(""); +// When editing an existing connection whose credentials live in the backend's +// secret store, refreshToken/clientSecret are intentionally not sent back to +// the UI. Track "we have previously authorized" separately from whether we +// still hold the tokens in-memory so the form can show a status chip. +const salesforceOauthPreviouslyAuthorized = ref(false); + +type SalesforceDevicePhase = "idle" | "polling" | "success" | "expired" | "denied" | "error"; +const salesforceDevicePhase = ref("idle"); +const salesforceDeviceUserCode = ref(""); +const salesforceDeviceVerificationUri = ref(""); +const salesforceDeviceCode = ref(""); +const salesforceDeviceIntervalSecs = ref(5); +const salesforceDeviceSecondsLeft = ref(0); +const salesforceDeviceError = ref(""); +const salesforceDeviceUserCodeCopied = ref(false); +const salesforceDeviceVerificationCopied = ref(false); + +let salesforceDevicePollTimer: ReturnType | null = null; +let salesforceDevicePollAbortId = 0; +let salesforceDeviceExpiresTimer: ReturnType | null = null; + +function salesforceAuthModeFromConfig(externalConfig: unknown, fallbackPassword?: string): SalesforceAuthMode { + const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined; + const mode = cfg?.auth?.mode; + if (mode === "oauth" || mode === "device" || mode === "password") return mode; + if (cfg?.auth && (cfg.auth.refreshToken || cfg.auth.clientId || cfg.auth.authorizedAt)) return "oauth"; + // No persisted auth context: if a password/token is present, treat as manual token. + if (fallbackPassword?.trim()) return "token"; + return "token"; +} + +function resetSalesforceOAuthFields(externalConfig?: unknown, fallbackPassword?: string) { + const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined; + const auth = cfg?.auth; + salesforceAuthMode.value = salesforceAuthModeFromConfig(externalConfig, fallbackPassword); + salesforceEnvironment.value = (auth?.environment as SalesforceEnvironment) || "production"; + salesforceLoginUrl.value = auth?.loginUrl?.trim() || ""; + salesforceClientId.value = auth?.clientId?.trim() || ""; + // Client secret is never round-tripped from the backend for security; the + // input is blank on edit and only sent when the user re-types it. + salesforceClientSecret.value = ""; + salesforceUsername.value = auth?.username?.trim() || ""; + salesforceUserPassword.value = ""; + salesforceOauthRunning.value = false; + salesforceOauthError.value = ""; + salesforceOauthSuccess.value = ""; + // A Client ID alone only means the form was filled in, not that anybody ever + // signed in; claiming "authorized" from it showed a false reassurance banner. + salesforceOauthPreviouslyAuthorized.value = !!(auth?.refreshToken || auth?.authorizedAt); + salesforceDevicePhase.value = "idle"; + salesforceDeviceUserCode.value = ""; + salesforceDeviceVerificationUri.value = ""; + salesforceDeviceCode.value = ""; + salesforceDeviceIntervalSecs.value = 5; + salesforceDeviceSecondsLeft.value = 0; + salesforceDeviceError.value = ""; + salesforceDeviceUserCodeCopied.value = false; + salesforceDeviceVerificationCopied.value = false; + salesforceDeviceStopPolling(); +} + +function hydrateSalesforceOAuthFields(value: unknown, fallbackPassword?: string) { + resetSalesforceOAuthFields(value, fallbackPassword); +} + +function salesforceBuildAuthorizeParams(): SalesforceOAuthAuthorizeParams { + const clientId = salesforceClientId.value.trim(); + if (!clientId) throw new Error(t("connection.salesforceOauthClientIdRequired")); + const environment = salesforceEnvironment.value; + const loginUrl = environment === "custom" ? salesforceLoginUrl.value.trim() : undefined; + if (environment === "custom" && !loginUrl) { + throw new Error(t("connection.salesforceOauthLoginUrlRequired")); + } + const clientSecret = salesforceClientSecret.value.trim() || undefined; + return { environment, loginUrl, clientId, clientSecret }; +} + +function salesforceApplyTokenToForm(token: { accessToken: string; instanceUrl: string }) { + form.value.password = token.accessToken; + // The backend normalizes bare hostnames; we mirror it so the card preview + // shows the final instance URL even before the backend canonicalizes it. + let instanceUrl = token.instanceUrl.trim(); + if (instanceUrl && !/^https?:\/\//i.test(instanceUrl)) { + instanceUrl = `https://${instanceUrl.replace(/\/+$/, "")}`; + } + if (instanceUrl) { + form.value.host = instanceUrl.replace(/\/+$/, ""); + } +} + +function salesforceBuildAuthContext(mode: SalesforceAuthMode, token?: { refreshToken?: string }): SalesforceAuthContext | undefined { + if (mode === "token") return undefined; + const context: SalesforceAuthContext = { + mode, + environment: salesforceEnvironment.value, + clientId: salesforceClientId.value.trim() || undefined, + authorizedAt: new Date().toISOString(), + }; + if (salesforceEnvironment.value === "custom") { + context.loginUrl = salesforceLoginUrl.value.trim() || undefined; + } + // Only forward the client secret when the user explicitly typed it in this + // session — otherwise we would overwrite the backend-stored secret with "". + if (salesforceClientSecret.value.trim()) { + context.clientSecret = salesforceClientSecret.value.trim(); + } + if (mode === "password") { + context.username = salesforceUsername.value.trim() || undefined; + if (salesforceUserPassword.value) { + context.password = salesforceUserPassword.value; + } + } + if (token?.refreshToken) { + context.refreshToken = token.refreshToken; + } + return context; +} + +function salesforceMergeAuthIntoExternalConfig(mode: SalesforceAuthMode, token?: { refreshToken?: string }) { + const existing = (form.value.external_config && typeof form.value.external_config === "object" ? (form.value.external_config as Record) : {}) as SalesforceExternalConfig & Record; + const auth = salesforceBuildAuthContext(mode, token); + if (!auth) { + const { auth: _dropped, ...rest } = existing; + form.value.external_config = Object.keys(rest).length > 0 ? rest : undefined; + return; + } + // Preserve an existing refreshToken when the user re-authorizes without the + // backend returning a new one (e.g. refresh flow that omits a rotation). + if (!auth.refreshToken && existing.auth?.refreshToken) { + auth.refreshToken = existing.auth.refreshToken; + } + // Same for the ROPC password: it is not round-tripped for display, so an + // edit-without-retype must not clobber the stored credential. + if (!auth.password && existing.auth?.password) { + auth.password = existing.auth.password; + } + form.value.external_config = { ...existing, auth }; +} + +async function salesforceStartBrowserAuthorize() { + salesforceOauthError.value = ""; + salesforceOauthSuccess.value = ""; + salesforceOauthPreviouslyAuthorized.value = false; + let params: SalesforceOAuthAuthorizeParams; + try { + params = salesforceBuildAuthorizeParams(); + } catch (e) { + salesforceOauthError.value = errorMessage(e); + return; + } + salesforceOauthRunning.value = true; + try { + const token = await api.salesforceOauthBrowserAuthorize(params); + salesforceApplyTokenToForm(token); + salesforceMergeAuthIntoExternalConfig("oauth", token); + salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl }); + salesforceOauthPreviouslyAuthorized.value = true; + } catch (e) { + salesforceOauthError.value = errorMessage(e); + } finally { + salesforceOauthRunning.value = false; + } +} + +async function salesforceStartPasswordLogin() { + salesforceOauthError.value = ""; + salesforceOauthSuccess.value = ""; + salesforceOauthPreviouslyAuthorized.value = false; + let params: SalesforceOAuthAuthorizeParams; + try { + params = salesforceBuildAuthorizeParams(); + } catch (e) { + salesforceOauthError.value = errorMessage(e); + return; + } + const username = salesforceUsername.value.trim(); + if (!username) { + salesforceOauthError.value = t("connection.salesforceUsernameRequired"); + return; + } + if (!salesforceUserPassword.value) { + salesforceOauthError.value = t("connection.salesforceUserPasswordRequired"); + return; + } + salesforceOauthRunning.value = true; + try { + const token = await api.salesforceOauthPasswordLogin(params, username, salesforceUserPassword.value); + salesforceApplyTokenToForm(token); + salesforceMergeAuthIntoExternalConfig("password", token); + salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl }); + salesforceOauthPreviouslyAuthorized.value = true; + } catch (e) { + salesforceOauthError.value = errorMessage(e); + } finally { + salesforceOauthRunning.value = false; + } +} + +function salesforceDeviceStopPolling() { + salesforceDevicePollAbortId++; + if (salesforceDevicePollTimer) { + clearTimeout(salesforceDevicePollTimer); + salesforceDevicePollTimer = null; + } + if (salesforceDeviceExpiresTimer) { + clearInterval(salesforceDeviceExpiresTimer); + salesforceDeviceExpiresTimer = null; + } +} + +function salesforceDeviceStartExpiryCountdown(expiresInSecs: number) { + salesforceDeviceSecondsLeft.value = Math.max(0, Math.floor(expiresInSecs)); + if (salesforceDeviceExpiresTimer) clearInterval(salesforceDeviceExpiresTimer); + salesforceDeviceExpiresTimer = setInterval(() => { + salesforceDeviceSecondsLeft.value = Math.max(0, salesforceDeviceSecondsLeft.value - 1); + if (salesforceDeviceSecondsLeft.value <= 0 && salesforceDevicePhase.value === "polling") { + salesforceDeviceStopPolling(); + salesforceDevicePhase.value = "expired"; + } + }, 1000); +} + +async function salesforceDeviceRequestCode() { + salesforceOauthError.value = ""; + salesforceOauthSuccess.value = ""; + salesforceOauthPreviouslyAuthorized.value = false; + salesforceDeviceError.value = ""; + salesforceDeviceUserCode.value = ""; + salesforceDeviceVerificationUri.value = ""; + salesforceDeviceUserCodeCopied.value = false; + salesforceDeviceVerificationCopied.value = false; + let params: SalesforceOAuthAuthorizeParams; + try { + params = salesforceBuildAuthorizeParams(); + } catch (e) { + salesforceDeviceError.value = errorMessage(e); + salesforceDevicePhase.value = "error"; + return; + } + salesforceOauthRunning.value = true; + salesforceDevicePhase.value = "idle"; + try { + const start = await api.salesforceOauthDeviceStart(params); + salesforceDeviceUserCode.value = start.userCode; + salesforceDeviceVerificationUri.value = start.verificationUri; + salesforceDeviceCode.value = start.deviceCode; + salesforceDeviceIntervalSecs.value = Math.max(1, start.intervalSecs || 5); + salesforceDevicePhase.value = "polling"; + salesforceDeviceStartExpiryCountdown(start.expiresInSecs); + await salesforceDevicePollLoop(params, start.deviceCode, salesforceDeviceIntervalSecs.value, salesforceDevicePollAbortId); + } catch (e) { + salesforceDeviceError.value = errorMessage(e); + salesforceDevicePhase.value = "error"; + salesforceDeviceStopPolling(); + } finally { + salesforceOauthRunning.value = false; + } +} + +async function salesforceDevicePollLoop(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number, abortId: number): Promise { + while (salesforceDevicePhase.value === "polling" && abortId === salesforceDevicePollAbortId) { + await new Promise((resolve) => { + salesforceDevicePollTimer = setTimeout(() => resolve(), intervalSecs * 1000); + }); + if (abortId !== salesforceDevicePollAbortId) return; + let result: SalesforceOAuthDevicePollResult; + try { + result = await api.salesforceOauthDevicePoll(params, deviceCode, intervalSecs); + } catch (e) { + // Transient network error: keep polling until the code expires. + if (salesforceDeviceSecondsLeft.value <= 0) { + salesforceDevicePhase.value = "expired"; + salesforceDeviceStopPolling(); + return; + } + salesforceDeviceError.value = errorMessage(e); + continue; + } + if (abortId !== salesforceDevicePollAbortId) return; + if (result.status === "pending") { + salesforceDeviceIntervalSecs.value = Math.max(1, result.intervalSecs || intervalSecs); + continue; + } + salesforceDeviceStopPolling(); + if (result.status === "success") { + salesforceApplyTokenToForm(result.token); + salesforceMergeAuthIntoExternalConfig("device", result.token); + salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: result.token.instanceUrl }); + salesforceOauthPreviouslyAuthorized.value = true; + salesforceDevicePhase.value = "success"; + return; + } + if (result.status === "expired") { + salesforceDevicePhase.value = "expired"; + return; + } + if (result.status === "denied") { + salesforceDeviceError.value = result.reason || t("connection.salesforceDeviceDenied"); + salesforceDevicePhase.value = "denied"; + return; + } + } +} + +function salesforceDeviceCancel() { + salesforceDeviceStopPolling(); + salesforceDevicePhase.value = "idle"; + salesforceDeviceUserCode.value = ""; + salesforceDeviceVerificationUri.value = ""; + salesforceDeviceCode.value = ""; +} + +function salesforceDeviceRestart() { + salesforceDeviceCancel(); + salesforceDeviceError.value = ""; + salesforceOauthError.value = ""; +} + +async function salesforceCopyUserCode() { + if (!salesforceDeviceUserCode.value) return; + await copyToClipboard(salesforceDeviceUserCode.value); + salesforceDeviceUserCodeCopied.value = true; + setTimeout(() => { + salesforceDeviceUserCodeCopied.value = false; + }, 1500); +} + +async function salesforceCopyVerificationUri() { + if (!salesforceDeviceVerificationUri.value) return; + await copyToClipboard(salesforceDeviceVerificationUri.value); + salesforceDeviceVerificationCopied.value = true; + setTimeout(() => { + salesforceDeviceVerificationCopied.value = false; + }, 1500); +} + +function salesforceOpenVerificationPage() { + if (!salesforceDeviceVerificationUri.value) return; + // Same pattern as the update-flow "open release notes" action: defer to the + // shell helper that picks the right opener for desktop vs. web. + void (async () => { + try { + if (isDesktop) { + const { open } = await import("@tauri-apps/plugin-shell"); + await open(salesforceDeviceVerificationUri.value); + } else { + globalThis.open(salesforceDeviceVerificationUri.value, "_blank", "noopener,noreferrer"); + } + } catch (e) { + salesforceDeviceError.value = errorMessage(e); + } + })(); +} + +function salesforceSetAuthMode(mode: SalesforceAuthMode) { + // Switching modes resets any in-flight state so stale tokens from one flow + // never leak into another. + if (salesforceOauthRunning.value && mode !== salesforceAuthMode.value) return; + salesforceAuthMode.value = mode; + salesforceOauthError.value = ""; + salesforceOauthSuccess.value = ""; + if (mode !== "device") { + salesforceDeviceCancel(); + salesforceDeviceError.value = ""; + } +} + watch(influxDbVersion, (version, previousVersion) => { if (form.value.db_type !== "influxdb") return; if (version === "2" || version === "3") { @@ -2546,6 +2932,9 @@ function applyProfile(val: string, preserveConnectionFields = false) { form.value.connection_string = undefined; form.value.url_params = ""; } + if (profile.type === "salesforce") { + resetSalesforceOAuthFields(form.value.external_config, form.value.password); + } resetHiveKerberosFields(profile.type === "hive" || profile.type === "argo" || profile.type === "kyuubi" || profile.type === "impala" ? form.value : undefined); } if (profile.type === "meilisearch") { @@ -2777,6 +3166,11 @@ watch( } else { resetVictoriaMetricsFields(); } + if (config.db_type === "salesforce") { + hydrateSalesforceOAuthFields(config.external_config, config.password); + } else { + resetSalesforceOAuthFields(undefined, undefined); + } resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined); resetHiveKerberosFields(config.db_type === "hive" || config.db_type === "argo" || config.db_type === "kyuubi" || config.db_type === "impala" ? config : undefined); resetDamengJvmOptions(config.db_type === "dameng" ? config : undefined); @@ -4426,6 +4820,51 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi config.connection_string = undefined; config.database = "metrics"; config.username = config.username.trim(); + } else if (config.db_type === "salesforce") { + // Token-mode connections never carry auth context (the access token itself is + // the credential in `password`). OAuth/device modes merge the auth context + // into `external_config.auth`; the backend handles refreshToken encryption. + config.connection_string = undefined; + config.database = undefined; + config.username = config.username.trim(); + const existing = (config.external_config && typeof config.external_config === "object" ? (config.external_config as Record) : {}) as SalesforceExternalConfig & Record; + if (salesforceAuthMode.value === "token") { + const { auth: _dropped, ...rest } = existing; + config.external_config = Object.keys(rest).length > 0 ? rest : undefined; + } else { + // Preserve an existing refreshToken/clientSecret when the user saves the + // form without re-authorizing or re-typing them (they're not round-tripped + // from the backend). The hydrate step flagged this via + // `salesforceOauthPreviouslyAuthorized`. + const auth: SalesforceAuthContext = { + mode: salesforceAuthMode.value, + environment: salesforceEnvironment.value, + clientId: salesforceClientId.value.trim() || existing.auth?.clientId, + authorizedAt: existing.auth?.authorizedAt, + }; + if (salesforceEnvironment.value === "custom") { + auth.loginUrl = salesforceLoginUrl.value.trim() || existing.auth?.loginUrl; + } + if (salesforceClientSecret.value.trim()) { + auth.clientSecret = salesforceClientSecret.value.trim(); + } else if (existing.auth?.clientSecret) { + // Preserve secret indicator without leaking the value to the client. + auth.clientSecret = existing.auth.clientSecret; + } + if (salesforceAuthMode.value === "password") { + auth.username = salesforceUsername.value.trim() || existing.auth?.username; + if (salesforceUserPassword.value) { + auth.password = salesforceUserPassword.value; + } else if (existing.auth?.password) { + // Stored ROPC credential (scrubbed into the backend secret store); + // keep it when the user edits the connection without retyping. + auth.password = existing.auth.password; + } + } else if (existing.auth?.refreshToken) { + auth.refreshToken = existing.auth.refreshToken; + } + config.external_config = { ...existing, auth }; + } } else if (config.db_type === "elasticsearch") { config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value, elasticsearchConnectivityCheckDisabled.value); } else if (config.db_type === "meilisearch") { @@ -5476,6 +5915,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) { appliedConnectionUrlInput.value = ""; resetMeilisearchHostInput(); oracleTnsAdminPath.value = ""; + resetSalesforceOAuthFields(undefined, undefined); if (!options.preservePickerState) { dialogStep.value = "select"; dbSearchQuery.value = ""; @@ -6366,6 +6806,9 @@ onMounted(async () => { onUnmounted(() => { unlistenAgentInstallProgress?.(); unlistenAgentInstallProgress = null; + // Stop any in-flight Salesforce device-code poll / expiry countdown so + // closing the dialog does not leave orphan timers running. + salesforceDeviceStopPolling(); }); function openExternalUrl(url: string) { @@ -6521,7 +6964,7 @@ function openExternalUrl(url: string) {
-
+
@@ -7943,6 +8386,289 @@ function openExternalUrl(url: string) { + + + diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 2e9d586acd..5bb3111d51 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -139,7 +139,9 @@ import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferenced const props = defineProps(); function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined { - return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect); + // clickhouse and soql ride the SQL editor but have no matching behavior dialect; + // fall back to the connection's dialect (undefined for Salesforce). + return props.syntaxDialect === "clickhouse" || props.syntaxDialect === "soql" ? props.dialect : (props.syntaxDialect ?? props.dialect); } function queryEditorSelectionLanguage(): "sql" | "text" { diff --git a/apps/desktop/src/components/editor/useQueryEditorBatchSelection.ts b/apps/desktop/src/components/editor/useQueryEditorBatchSelection.ts index e02d480b86..84497255a0 100644 --- a/apps/desktop/src/components/editor/useQueryEditorBatchSelection.ts +++ b/apps/desktop/src/components/editor/useQueryEditorBatchSelection.ts @@ -9,9 +9,10 @@ import { type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsea import { type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive } from "@/lib/editor/batchColumnSelection"; import { type RedisCompletionItem } from "@/lib/redis/redisCompletion"; +import { type SoqlCompletionItem } from "@/lib/soql/soqlCompletion"; import type { SqlCompletionItem } from "@/lib/sql/sqlCompletion"; -export type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem; +export type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem | SoqlCompletionItem; export interface BatchColumnSelectionActionItem { label: string; diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 695046c1f6..db41d3ec05 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -25,6 +25,7 @@ import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion" import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { buildElasticsearchCompletionItemsFromContext, elasticsearchCompletionNeedsFields, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion"; import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion } from "@/lib/mongo/mongoCompletion"; +import { buildSoqlCompletionItems, getSoqlCompletionContext, getSoqlCompletionResultValidFor, resolveSoqlFieldCandidates, resolveSoqlValueField, shouldAutoOpenSoqlCompletion, soqlCompletionNeedsObjects, type SoqlCompletionField, type SoqlCompletionObject } from "@/lib/soql/soqlCompletion"; import { buildSqlServerUseDatabaseCompletionItems, mergeSqlCompletionQualifierNames, @@ -361,7 +362,16 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } function shouldInsertSqlCompletionSpace(): boolean { - return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "solr" && props.databaseType !== "victoriametrics"; + return ( + props.databaseType !== "mongodb" && + props.databaseType !== "redis" && + props.databaseType !== "elasticsearch" && + props.databaseType !== "easysearch" && + props.databaseType !== "meilisearch" && + props.databaseType !== "solr" && + props.databaseType !== "victoriametrics" && + props.databaseType !== "salesforce" + ); } // Snippet expansion normally follows from the item type; a provider can also @@ -591,6 +601,59 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) }; } + async function provideSoqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) { + if (!props.connectionId) return null; + const epoch = ++completionEpoch; + const fullDoc = currentState.doc.toString(); + if (!explicit && !shouldAutoOpenSoqlCompletion(fullDoc, position)) return null; + + const completionContext = getSoqlCompletionContext(fullDoc, position); + if (completionContext.mode === "none") return null; + // The Salesforce backend ignores the database parameter for metadata (the whole + // org is one synthesized database), so completion must not gate on props.database + // being populated — a restored/toolbar-opened tab can carry an empty database + // while the connection works fine. Pass it through (possibly "") and let the + // store/backend resolve it. + const database = props.database ?? ""; + + let objects: SoqlCompletionObject[] = []; + let fields: SoqlCompletionField[] = []; + let valueField: SoqlCompletionField | null = null; + + // Field loader backed by the store (which reads the backend describe cache), so + // relationship traversal costs at most one describe per distinct sObject. + const loadFields = (objectName: string) => connectionStore.listSoqlCompletionFields(props.connectionId!, database, objectName); + + try { + if (soqlCompletionNeedsObjects(completionContext.mode)) { + objects = await connectionStore.listSoqlCompletionObjects(props.connectionId, database); + } + if (completionContext.mode === "field") { + fields = await resolveSoqlFieldCandidates(completionContext, loadFields); + } + if (completionContext.mode === "value") { + valueField = await resolveSoqlValueField(completionContext, loadFields); + } + } catch (error) { + // Metadata load failed (offline, describe error, no cache yet): fall back to + // keyword/function-only completion. Logged so a persistent empty field list is + // diagnosable instead of silently looking like "custom fields are excluded". + console.debug("[soql-completion] metadata load failed", error); + objects = []; + fields = []; + valueField = null; + } + if (epoch !== completionEpoch) return null; + + const items = buildSoqlCompletionItems(completionContext, { objects, fields, valueField }); + if (items.length === 0) return null; + return { + from: completionContext.from, + options: items.map((item) => completionOptionForItem(item)), + validFor: getSoqlCompletionResultValidFor(completionContext), + }; + } + async function provideSqlCompletions(context: CompletionContext) { const currentState = context.state; const position = context.pos; @@ -612,6 +675,9 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) return provideRedisCompletions(currentState, position, explicit); } if (props.databaseType === "victoriametrics") return null; + if (props.databaseType === "salesforce") { + return provideSoqlCompletions(currentState, position, explicit); + } const hasDatabase = props.database != null; const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType); const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType); diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index a5a08632f3..88907a7c6c 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -195,7 +195,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions } function shouldSkipSqlSemanticDiagnostics() { - return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); + return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); } function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean { @@ -337,7 +337,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions setSemanticDiagnostics([]); return; } - if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "solr" || props.databaseType === "victoriametrics") { + if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "solr" || props.databaseType === "victoriametrics" || props.databaseType === "salesforce") { setSemanticDiagnostics([]); return; } diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 51d32e5efa..24b808431d 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -115,7 +115,7 @@ const exportElapsedText = computed(() => { return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value); }); -const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type))); +const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type))); const canExport = computed(() => { const hasContent = includeStructure.value || includeData.value || includeObjects.value; diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e55d4b9992..a7a169391e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -119,6 +119,8 @@ import { hiveTablePropertiesIndicateTransactional, isClickHouseExistingRowReadonlyColumn, isHiddenGridColumn, + isSalesforceExistingRowReadonlyColumn, + isSalesforceNewRowReadonlyColumn, isTdengineExistingRowReadonlyColumn, shouldIncludeSyntheticRowId, } from "@/lib/table/tableEditing"; @@ -369,6 +371,7 @@ import { useDataGridColumnFormatter } from "@/composables/useDataGridColumnForma import { useDataGridTableMetadataLoaders } from "@/composables/useDataGridTableMetadataLoaders"; import { DATA_GRID_SERVER_COLUMN_FILTER_LIMIT, useDataGridColumnFilters } from "@/composables/useDataGridColumnFilters"; import { useDataGridLargeValues } from "@/composables/useDataGridLargeValues"; +import { useSalesforceSaveConfirmation } from "@/composables/useSalesforceSaveConfirmation"; const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue")); const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue")); @@ -3797,6 +3800,57 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap resolvedDatabaseType.value === "salesforce"); +const salesforceIdentity = computed(() => (isSalesforceGrid.value && props.connectionId ? connectionStore.salesforceCurrentUser(props.connectionId) : null)); +const salesforceIdentityLabel = computed(() => { + const identity = salesforceIdentity.value; + if (!identity) return ""; + return identity.username || identity.name || identity.email; +}); +const salesforceSaveConfirmSummary = computed(() => { + const parts: string[] = []; + if (salesforceSaveConfirmUpdates.value > 0) parts.push(t("grid.salesforceSaveUpdates", { count: salesforceSaveConfirmUpdates.value })); + if (salesforceSaveConfirmInserts.value > 0) parts.push(t("grid.salesforceSaveInserts", { count: salesforceSaveConfirmInserts.value })); + if (salesforceSaveConfirmDeletes.value > 0) parts.push(t("grid.salesforceSaveDeletes", { count: salesforceSaveConfirmDeletes.value })); + return parts.join(" · "); +}); +// The save dialog names the profile alongside the user: writability comes from +// the profile's FLS plus record sharing, not from the admin flag alone. +const salesforceIdentityProfile = computed(() => { + const profileName = salesforceIdentity.value?.profileName; + return profileName ? t("grid.salesforceSaveProfile", { name: profileName }) : t("toolbar.salesforceIdentityUnknownProfile"); +}); +const salesforceSaveConfirmDetails = computed(() => { + const lines = [salesforceSaveConfirmSummary.value, t("grid.salesforceSaveTarget", { object: salesforceSaveConfirmTarget.value || t("grid.salesforceSaveUnknownObject") })]; + if (salesforceIdentity.value) { + const identity = { user: salesforceIdentityLabel.value, profile: salesforceIdentityProfile.value }; + if (salesforceIdentity.value.isAdmin === true) lines.push(t("grid.salesforceSaveAdminIdentity", identity)); + else if (salesforceIdentity.value.isAdmin === false) lines.push(t("grid.salesforceSaveNonAdminIdentity", identity)); + else lines.push(t("grid.salesforceSaveUnknownRights", identity)); + } + return lines.filter((line) => !!line).join("\n"); +}); +const salesforceSaveConfirmSql = computed(() => salesforceSaveConfirmStatements.value.join("\n")); +watch(salesforceSaveConfirmOpen, (isOpen) => { + if (!isOpen || !props.connectionId) return; + void connectionStore.loadSalesforceCurrentUser(props.connectionId); +}); + const editor = useDataGridEditor({ result: computed(() => props.result), editable: computed(() => props.editable), @@ -3821,6 +3875,8 @@ const editor = useDataGridEditor({ rowStatusFilter, dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry), confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution), + // Only Salesforce opts in: its writes cannot be rolled back once issued. + confirmSaveRequest: computed(() => (isSalesforceGrid.value ? requestSalesforceSaveConfirmation : undefined)), includeDatabaseNameInSaveSql: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName), initialEditColumn: firstVisibleColumnIndex, cellEditorText: cellEditorTextForValue, @@ -4018,10 +4074,14 @@ function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolea if (isSavingNewRow(item)) return false; const column = props.result.columns[columnIndex] ?? ""; if (customReadonlyColumns.value.has(column.toLowerCase())) return false; - if (!item?.isNew && !item?.isDraft) { - const sourceColumn = props.sourceColumns?.[columnIndex] ?? column; + const sourceColumn = props.sourceColumns?.[columnIndex] ?? column; + if (item?.isNew || item?.isDraft) { + // A new Salesforce record cannot carry non-createable fields (Id, CreatedDate, …). + if (isSalesforceNewRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.columns ?? [])) return false; + } else { if (isClickHouseExistingRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.primaryKeys ?? [], props.tableMeta?.columns ?? [])) return false; if (isTdengineExistingRowReadonlyColumn(props.databaseType, column, props.tableMeta?.columns ?? [])) return false; + if (isSalesforceExistingRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.primaryKeys ?? [], props.tableMeta?.columns ?? [])) return false; } return true; } @@ -14211,6 +14271,18 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val :close-on-confirm="false" @confirm="confirmDropAllMongoIndexes" /> + + = { jdbcx: "jdbcx", mqtt: "mqtt", dolt: "dolt", + salesforce: "salesforce", }; const normalizedType = computed(() => (props.dbType || "").toLowerCase().replace(/[\s-]+/g, "_")); diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue index 2ba0a6ebae..d6fbaf7928 100644 --- a/apps/desktop/src/components/layout/EditorToolbar.vue +++ b/apps/desktop/src/components/layout/EditorToolbar.vue @@ -1,7 +1,7 @@