diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 58205d44fe..c857972fbd 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -37,6 +37,7 @@ import { agentDriverInstallKey, appendAgentDriverUpdateHint, hasAgentDriverUpdat import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver"; import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection"; import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout"; +import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue"; import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases"; @@ -918,6 +919,7 @@ function errorMessage(error: unknown): string { } function connectionErrorWithDriverUpdateHint(config: ConnectionConfig, message: string): string { + message = appendConnectionErrorHints(config, message, t); if (!hasAgentDriverUpdate(config.db_type, agentDrivers.value, config.driver_profile)) return message; return appendAgentDriverUpdateHint(message, t("connection.agentDriverUpdateConnectionHint")); } @@ -3039,7 +3041,7 @@ async function save() { const message = String(e?.message || e); if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return; if (config.one_time) void store.removeConnection(config.id); - emit("connectFailed", mongodbAuthFailureHint(message)); + emit("connectFailed", appendConnectionErrorHints(config, mongodbAuthFailureHint(message), t)); }); return; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d6cdcfe22c..a3148745f8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -215,6 +215,7 @@ export default { mysqlClientCertPlaceholder: "/path/to/client.crt", mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "Client certificate and private key must be provided together when MySQL requires mTLS.", + mysqlTlsConnectionFailureHint: "If the MySQL server does not require TLS, edit the connection, set TLS Mode to Disabled, and try again.", mysqlClientCertBrowse: "Choose client certificate", mysqlClientKeyBrowse: "Choose client private key", postgresSslMode: "TLS Mode", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5ae7508d9c..e2d5dfb957 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -217,6 +217,7 @@ export default withEnglishFallback({ mysqlClientCertPlaceholder: "/ruta/a/client.crt", mysqlClientKeyPlaceholder: "/ruta/a/client.key", mysqlClientCertHint: "El certificado y la clave privada deben indicarse juntos si MySQL requiere mTLS.", + mysqlTlsConnectionFailureHint: "Si el servidor MySQL no requiere TLS, edita la conexión, establece el Modo TLS en Deshabilitado e inténtalo de nuevo.", mysqlClientCertBrowse: "Elegir certificado cliente", mysqlClientKeyBrowse: "Elegir clave privada cliente", postgresSslMode: "Modo TLS", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 438a5057a0..e2bf839e69 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -216,6 +216,7 @@ export default withEnglishFallback({ mysqlClientCertPlaceholder: "/percorso/per/client.crt", mysqlClientKeyPlaceholder: "/percorso/per/client.key", mysqlClientCertHint: "Il certificato client e la chiave privata devono essere forniti insieme quando MySQL richiede mTLS.", + mysqlTlsConnectionFailureHint: "Se il server MySQL non richiede TLS, modifica la connessione, imposta la modalità TLS su Disabilitata e riprova.", mysqlClientCertBrowse: "Scegli certificato client", mysqlClientKeyBrowse: "Scegli chiave privata client", postgresSslMode: "Modalità TLS", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 5703d9e6ee..2ce41b2edb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -216,6 +216,7 @@ export default withEnglishFallback({ mysqlClientCertPlaceholder: "/path/to/client.crt", mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQLがmTLSを要求する場合、クライアント証明書と秘密鍵の両方を指定する必要があります。", + mysqlTlsConnectionFailureHint: "MySQLサーバーがTLSを要求しない場合は、接続を編集し、TLSモードを無効にしてから再試行してください。", mysqlClientCertBrowse: "クライアント証明書を選択", mysqlClientKeyBrowse: "クライアント秘密鍵を選択", postgresSslMode: "TLSモード", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 75443af803..0f4f0b8805 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -217,6 +217,7 @@ export default withEnglishFallback({ mysqlClientCertPlaceholder: "/caminho/para/client.crt", mysqlClientKeyPlaceholder: "/caminho/para/client.key", mysqlClientCertHint: "O certificado e a chave privada do cliente devem ser fornecidos juntos quando o MySQL exige mTLS.", + mysqlTlsConnectionFailureHint: "Se o servidor MySQL não exigir TLS, edite a conexão, defina o Modo TLS como Desabilitado e tente novamente.", mysqlClientCertBrowse: "Escolher certificado do cliente", mysqlClientKeyBrowse: "Escolher chave privada do cliente", postgresSslMode: "Modo TLS", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 64346c2808..fe58b0f139 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -217,6 +217,7 @@ export default withEnglishFallback({ mysqlClientCertPlaceholder: "/path/to/client.crt", mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQL 要求 mTLS 时,客户端证书和私钥必须一起填写。", + mysqlTlsConnectionFailureHint: "如果 MySQL 服务端不要求 TLS,请编辑连接,在 TLS 模式中选择“禁用”后重试。", mysqlClientCertBrowse: "选择客户端证书", mysqlClientKeyBrowse: "选择客户端私钥", postgresSslMode: "TLS 模式", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f48d073d1b..6fc9ecf7e0 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -217,6 +217,7 @@ export default withEnglishFallback({ mysqlClientCertPlaceholder: "/path/to/client.crt", mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQL 要求 mTLS 時,用戶端憑證和私鑰必須一起填寫。", + mysqlTlsConnectionFailureHint: "如果 MySQL 伺服器不要求 TLS,請編輯連線,在 TLS 模式中選擇「停用」後重試。", mysqlClientCertBrowse: "選擇用戶端憑證", mysqlClientKeyBrowse: "選擇用戶端私鑰", postgresSslMode: "TLS 模式", diff --git a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts new file mode 100644 index 0000000000..419ab7792a --- /dev/null +++ b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; +import type { ConnectionConfig } from "@/types/database"; + +function mysqlConfig(urlParams: string | undefined): ConnectionConfig { + return { + id: "mysql-test", + name: "MySQL", + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "", + database: undefined, + url_params: urlParams, + ssl: false, + }; +} + +const t = (key: string) => (key === "connection.mysqlTlsConnectionFailureHint" ? "Set TLS Mode to Disabled." : key); + +describe("appendConnectionErrorHints", () => { + it("adds a MySQL TLS hint for non-disabled TLS failures", () => { + const message = appendConnectionErrorHints(mysqlConfig("ssl-mode=preferred"), "MySQL connection failed: TLS handshake failed", t); + + expect(message).toContain("TLS handshake failed"); + expect(message).toContain("Set TLS Mode to Disabled."); + }); + + it("adds the TLS hint for camel-case MySQL sslMode params", () => { + const message = appendConnectionErrorHints(mysqlConfig("sslMode=REQUIRED"), "MySQL connection failed: Driver error: `Client asked for SSL but server does not have this capability'", t); + + expect(message).toContain("server does not have this capability"); + expect(message).toContain("Set TLS Mode to Disabled."); + }); + + it("does not add the TLS hint when MySQL TLS is disabled", () => { + const message = appendConnectionErrorHints(mysqlConfig("ssl-mode=disabled"), "MySQL connection failed: TLS handshake failed", t); + + expect(message).toBe("MySQL connection failed: TLS handshake failed"); + }); + + it("does not add the TLS hint for non-TLS errors", () => { + const message = appendConnectionErrorHints(mysqlConfig("ssl-mode=preferred"), "Access denied for user root", t); + + expect(message).toBe("Access denied for user root"); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionErrorHints.ts b/apps/desktop/src/lib/connection/connectionErrorHints.ts new file mode 100644 index 0000000000..0fa71b45a6 --- /dev/null +++ b/apps/desktop/src/lib/connection/connectionErrorHints.ts @@ -0,0 +1,46 @@ +import type { ConnectionConfig } from "@/types/database"; + +type Translate = (key: string) => string; + +function normalizeUrlParams(params: string | undefined): URLSearchParams { + return new URLSearchParams((params || "").trim().replace(/^\?/, "")); +} + +function normalizeUrlParamKey(key: string): string { + return key.trim().toLowerCase().replace(/[-_]/g, ""); +} + +function urlParamValue(params: URLSearchParams, key: string): string { + const normalizedKey = normalizeUrlParamKey(key); + for (const [paramKey, value] of params.entries()) { + if (normalizeUrlParamKey(paramKey) === normalizedKey) return value; + } + return ""; +} + +function mysqlTlsMode(config: ConnectionConfig): string { + const parsed = normalizeUrlParams(config.url_params); + // MySQL clients use ssl-mode, sslmode and sslMode spellings; keep hint behavior aligned with backend parsing. + const mode = urlParamValue(parsed, "ssl-mode").trim().toLowerCase().replace("-", "_"); + if (["disabled", "disable"].includes(mode)) return "disabled"; + if (["preferred", "prefer"].includes(mode)) return "preferred"; + if (["required", "require", "verify_ca", "verify_identity"].includes(mode)) return mode; + if (config.ssl || ["true", "1", "yes", "on"].includes(urlParamValue(parsed, "require_ssl").trim().toLowerCase())) return "required"; + return "disabled"; +} + +function isMysqlTlsLikeFailure(message: string): boolean { + const text = message.toLowerCase(); + return ( + (text.includes("mysql") || text.includes("mariadb") || text.includes("tidb") || text.includes("tls") || text.includes("ssl")) && + (text.includes("tls") || text.includes("ssl") || text.includes("handshake") || text.includes("certificate") || text.includes("cert") || text.includes("unknown ca") || text.includes("self signed")) + ); +} + +export function appendConnectionErrorHints(config: ConnectionConfig | undefined, message: string, t: Translate): string { + if (!config || config.db_type !== "mysql") return message; + if (mysqlTlsMode(config) === "disabled") return message; + if (!isMysqlTlsLikeFailure(message)) return message; + const hint = t("connection.mysqlTlsConnectionFailureHint"); + return message.includes(hint) ? message : `${message}\n\n${hint}`; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 474b711ac0..f760c2bbc7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -60,6 +60,7 @@ import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metad import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; +import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator"; import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache"; @@ -522,6 +523,7 @@ export const useConnectionStore = defineStore("connection", () => { function connectionErrorWithDriverUpdateHint(config: ConnectionConfig | undefined, message: string): string { if (!config) return message; + message = appendConnectionErrorHints(config, message, i18n.global.t); if (!hasAgentDriverUpdate(config.db_type, agentDrivers.value, config.driver_profile)) return message; return appendAgentDriverUpdateHint(message, agentDriverUpdateHint()); }