diff --git a/apps/desktop/src/components/connection/ConnectionPasswordPromptDialog.vue b/apps/desktop/src/components/connection/ConnectionPasswordPromptDialog.vue index 3856992f91..6a615ee23d 100644 --- a/apps/desktop/src/components/connection/ConnectionPasswordPromptDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionPasswordPromptDialog.vue @@ -11,6 +11,7 @@ const { t } = useI18n(); const promptStore = useConnectionPasswordPromptStore(); const password = ref(""); +const rememberPassword = ref(false); const resolving = ref(false); const open = computed({ get: () => !!promptStore.pending, @@ -24,6 +25,7 @@ watch( () => promptStore.pending, () => { password.value = ""; + rememberPassword.value = false; resolving.value = false; }, ); @@ -31,7 +33,7 @@ watch( function submit() { if (resolving.value || !password.value) return; resolving.value = true; - promptStore.submit(password.value); + promptStore.submit(password.value, rememberPassword.value); } function cancel() { @@ -55,6 +57,10 @@ function cancel() {
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d7650cd384..296e5ff369 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -444,10 +444,13 @@ export default { savePassword: "Save password", savePasswordHint: "Uncheck to require entering it on every connect", promptPasswordTitle: "Enter password", - promptPasswordMessage: "Enter the password for connection “{connection}”. It will not be saved locally.", + promptPasswordMessage: "Enter the password for connection “{connection}”.", promptPasswordPlaceholder: "Password", promptPasswordSubmit: "Connect", promptPasswordCancel: "Cancel", + promptRememberPassword: "Remember password", + rememberPasswordSaveFailed: "Connected, but DBX could not remember the password: {message}", + mysqlMissingPasswordHint: "No database password was sent. Enter the password and try again.", database: "Database", databasePlaceholder: "Optional", databasePlaceholderRequired: "Required, enter an existing database", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 102f3e337e..be0661d479 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -445,10 +445,13 @@ export default withEnglishFallback({ savePassword: "Guardar contraseña", savePasswordHint: "Desmarcar para solicitarla en cada conexión", promptPasswordTitle: "Introducir contraseña", - promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”. No se guardará localmente.", + promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”.", promptPasswordPlaceholder: "Contraseña", promptPasswordSubmit: "Conectar", promptPasswordCancel: "Cancelar", + promptRememberPassword: "Recordar contraseña", + rememberPasswordSaveFailed: "Conectado, pero DBX no pudo recordar la contraseña: {message}", + mysqlMissingPasswordHint: "No se envió ninguna contraseña de base de datos. Introduzca la contraseña y vuelva a intentarlo.", database: "Base de datos", databasePlaceholder: "Opcional", databasePlaceholderRequired: "Obligatorio, ingresa una base de datos existente", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d499c79a47..91015d72d8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -444,10 +444,13 @@ export default withEnglishFallback({ savePassword: "Salva password", savePasswordHint: "Deseleziona per richiederla a ogni connessione", promptPasswordTitle: "Inserisci password", - promptPasswordMessage: "Inserisci la password per la connessione “{connection}”. Non verrà salvata localmente.", + promptPasswordMessage: "Inserisci la password per la connessione “{connection}”.", promptPasswordPlaceholder: "Password", promptPasswordSubmit: "Connetti", promptPasswordCancel: "Annulla", + promptRememberPassword: "Ricorda password", + rememberPasswordSaveFailed: "Connessione riuscita, ma DBX non ha potuto memorizzare la password: {message}", + mysqlMissingPasswordHint: "Non è stata inviata alcuna password del database. Inserisci la password e riprova.", database: "Database", databasePlaceholder: "Opzionale", databasePlaceholderRequired: "Obbligatorio, inserisci un database esistente", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ddce095cc2..b848bc8f57 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -444,10 +444,13 @@ export default withEnglishFallback({ savePassword: "パスワードを保存", savePasswordHint: "チェックを外すと、接続のたびに入力が必要です", promptPasswordTitle: "パスワードを入力", - promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。ローカルには保存されません。", + promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。", promptPasswordPlaceholder: "パスワード", promptPasswordSubmit: "接続", promptPasswordCancel: "キャンセル", + promptRememberPassword: "パスワードを保存", + rememberPasswordSaveFailed: "接続しましたが、DBX はパスワードを保存できませんでした: {message}", + mysqlMissingPasswordHint: "データベースのパスワードが送信されていません。パスワードを入力して再試行してください。", database: "データベース", databasePlaceholder: "任意", databasePlaceholderRequired: "必須、既存のデータベースを入力", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index dccdbc59ed..f4e7cd1c8d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -438,10 +438,13 @@ export default withEnglishFallback({ savePassword: "비밀번호 저장", savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다", promptPasswordTitle: "비밀번호 입력", - promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요. 로컬에 저장되지 않습니다.", + promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요.", promptPasswordPlaceholder: "비밀번호", promptPasswordSubmit: "연결", promptPasswordCancel: "취소", + promptRememberPassword: "비밀번호 기억", + rememberPasswordSaveFailed: "연결되었지만 DBX가 비밀번호를 저장하지 못했습니다: {message}", + mysqlMissingPasswordHint: "데이터베이스 비밀번호가 전송되지 않았습니다. 비밀번호를 입력한 후 다시 시도하세요.", database: "데이터베이스", databasePlaceholder: "선택", databasePlaceholderRequired: "필수, 기존 데이터베이스 입력", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7b7a865177..421c0a29d2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -445,10 +445,13 @@ export default withEnglishFallback({ savePassword: "Salvar senha", savePasswordHint: "Desmarque para exigir o preenchimento em cada conexão", promptPasswordTitle: "Digite a senha", - promptPasswordMessage: "Digite a senha da conexão “{connection}”. Ela não será salva localmente.", + promptPasswordMessage: "Digite a senha da conexão “{connection}”.", promptPasswordPlaceholder: "Senha", promptPasswordSubmit: "Conectar", promptPasswordCancel: "Cancelar", + promptRememberPassword: "Lembrar senha", + rememberPasswordSaveFailed: "Conectado, mas o DBX não conseguiu lembrar a senha: {message}", + mysqlMissingPasswordHint: "Nenhuma senha do banco de dados foi enviada. Digite a senha e tente novamente.", database: "Banco de dados", databasePlaceholder: "Opcional", databasePlaceholderRequired: "Obrigatório, informe um banco de dados existente", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 487d57a624..97a338453a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -369,10 +369,13 @@ export default withEnglishFallback({ savePassword: "本地保存密码", savePasswordHint: "取消勾选后,每次连接都需要输入密码", promptPasswordTitle: "输入密码", - promptPasswordMessage: "请输入连接“{connection}”的密码,该密码不会保存在本地。", + promptPasswordMessage: "请输入连接“{connection}”的密码。", promptPasswordPlaceholder: "密码", promptPasswordSubmit: "连接", promptPasswordCancel: "取消", + promptRememberPassword: "记住密码", + rememberPasswordSaveFailed: "连接成功,但 DBX 无法记住密码:{message}", + mysqlMissingPasswordHint: "本次连接没有携带数据库密码,请填写密码后重试。", database: "数据库", databasePlaceholder: "可选", databasePlaceholderRequired: "必填,请填写已存在的数据库", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2cf5f5d6d2..517c24c00f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -445,10 +445,13 @@ export default withEnglishFallback({ savePassword: "本機儲存密碼", savePasswordHint: "取消勾選後,每次連線都需要輸入密碼", promptPasswordTitle: "輸入密碼", - promptPasswordMessage: "請輸入連線「{connection}」的密碼,該密碼不會儲存在本機。", + promptPasswordMessage: "請輸入連線「{connection}」的密碼。", promptPasswordPlaceholder: "密碼", promptPasswordSubmit: "連線", promptPasswordCancel: "取消", + promptRememberPassword: "記住密碼", + rememberPasswordSaveFailed: "連線成功,但 DBX 無法記住密碼:{message}", + mysqlMissingPasswordHint: "本次連線沒有攜帶資料庫密碼,請填寫密碼後重試。", database: "資料庫", databasePlaceholder: "可選", databasePlaceholderRequired: "必填,請填寫已存在的資料庫", diff --git a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts index db9d5a88d4..8e416c6e29 100644 --- a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts +++ b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts @@ -33,6 +33,7 @@ function jdbcConfig(): ConnectionConfig { const t = (key: string) => { if (key === "connection.mysqlTlsConnectionFailureHint") return "Set TLS Mode to Disabled."; + if (key === "connection.mysqlMissingPasswordHint") return "No database password was sent."; if (key === "connection.jdbcMissingRuntimeDependencyHint") return "Install from Maven or import every dependency JAR."; return key; }; @@ -64,6 +65,20 @@ describe("appendConnectionErrorHints", () => { expect(message).toBe("Access denied for user root"); }); + it("replaces a passwordless MySQL access-denied error with an actionable message", () => { + const message = appendConnectionErrorHints(mysqlConfig(undefined), "MySQL connection failed: Server error: `ERROR 1045 (28000): Access denied for user 'root'@'192.168.100.133' (using password: NO)'", t); + + expect(message).toBe("No database password was sent."); + expect(message).not.toContain("192.168.100.133"); + }); + + it("keeps the native MySQL access-denied error when a password was supplied", () => { + const config = { ...mysqlConfig(undefined), password: "wrong-password" }; + const error = "Access denied for user 'root'@'192.168.100.133' (using password: YES)"; + + expect(appendConnectionErrorHints(config, error, t)).toBe(error); + }); + it("adds an installation hint when a custom JDBC driver is missing a runtime dependency", () => { const error = "Missing Java class com.alibaba.fastjson.JSONException. Install the required runtime dependency."; const message = appendConnectionErrorHints(jdbcConfig(), error, t); diff --git a/apps/desktop/src/lib/connection/connectionErrorHints.ts b/apps/desktop/src/lib/connection/connectionErrorHints.ts index 606366ad6f..2192b6d147 100644 --- a/apps/desktop/src/lib/connection/connectionErrorHints.ts +++ b/apps/desktop/src/lib/connection/connectionErrorHints.ts @@ -37,6 +37,11 @@ function isMysqlTlsLikeFailure(message: string): boolean { ); } +export function isMysqlMissingPasswordFailure(config: ConnectionConfig, message: string): boolean { + if (config.db_type !== "mysql" || config.password) return false; + return /access denied for user[\s\S]*using password:\s*no/i.test(message); +} + export function isJdbcMissingRuntimeDependencyError(message: string): boolean { return /Missing Java class|NoClassDefFoundError|ClassNotFoundException/i.test(message); } @@ -52,6 +57,13 @@ export function appendConnectionErrorHints(config: ConnectionConfig | undefined, result = appendHint(result, t("connection.jdbcMissingRuntimeDependencyHint")); } if (config.db_type !== "mysql") return result; + // MySQL includes the client's source IP in this error, which is easy to + // mistake for a host rewritten by sync. When no password was sent, lead + // with the actionable fix and reserve the native grant error for attempts + // that actually supplied credentials. + if (isMysqlMissingPasswordFailure(config, message)) { + return t("connection.mysqlMissingPasswordHint"); + } if (mysqlTlsMode(config) === "disabled") return message; if (!isMysqlTlsLikeFailure(message)) return message; const hint = t("connection.mysqlTlsConnectionFailureHint"); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts index a4c90216d0..51d29440a1 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts @@ -12,6 +12,16 @@ function installLocalStorage() { }); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function postgresConnection(overrides: Partial = {}): ConnectionConfig { return { id: "pg-1", @@ -101,7 +111,7 @@ describe("connectionStore save_password opt-out", () => { it("connect prompts for the password and uses it only for connectDb", async () => { installApiMocks(); installPasswordPromptMock(); - requestPassword.mockResolvedValue("typed-pw"); + requestPassword.mockResolvedValue({ password: "typed-pw", rememberPassword: false }); const { useConnectionStore } = await import("@/stores/connectionStore"); const store = useConnectionStore(); const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" }); @@ -145,4 +155,174 @@ describe("connectionStore save_password opt-out", () => { expect(requestPassword).not.toHaveBeenCalled(); }); + + it("prompts and retries when MySQL rejects a synced connection that sent no password", async () => { + const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1"); + installApiMocks({ connectDb }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "typed-pw", rememberPassword: true }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ + id: "mysql-1", + name: "Synced MySQL", + db_type: "mysql", + port: 3306, + username: "root", + save_password: true, + password: "", + }); + store.connections = [connection]; + + await store.connect(connection); + + expect(requestPassword).toHaveBeenCalledWith({ connectionId: "mysql-1", connectionName: "Synced MySQL" }); + expect(connectDb).toHaveBeenCalledTimes(2); + expect(connectDb).toHaveBeenNthCalledWith(1, expect.objectContaining({ password: "" }), expect.any(Number)); + expect(connectDb).toHaveBeenNthCalledWith(2, expect.objectContaining({ password: "typed-pw" }), expect.any(Number)); + const { saveConnections } = await import("@/lib/backend/api"); + expect(saveConnections).toHaveBeenLastCalledWith(expect.arrayContaining([expect.objectContaining({ id: "mysql-1", password: "typed-pw", save_password: true })])); + expect(store.getConfig("mysql-1")?.password).toBe("typed-pw"); + }); + + it("does not persist a recovered password after the connection config changes", async () => { + const connected = deferred(); + const connectDb = vi + .fn() + .mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")) + .mockImplementationOnce(() => connected.promise); + const saveConnections = vi.fn().mockResolvedValue(undefined); + installApiMocks({ connectDb, saveConnections }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "old-endpoint-password", rememberPassword: true }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ + id: "mysql-1", + name: "Synced MySQL", + db_type: "mysql", + host: "old.example.com", + port: 3306, + username: "root", + save_password: true, + password: "", + }); + store.connections = [connection]; + + const connecting = store.connect(connection); + await vi.waitFor(() => expect(connectDb).toHaveBeenCalledTimes(2)); + await store.updateConnection({ ...connection, host: "new.example.com" }); + connected.resolve("mysql-1"); + await connecting; + + expect(saveConnections).toHaveBeenCalledTimes(1); + expect(saveConnections).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "mysql-1", + host: "new.example.com", + password: "", + }), + ]), + ); + expect(store.getConfig("mysql-1")).toEqual(expect.objectContaining({ host: "new.example.com", password: "" })); + }); + + it("restores the latest config when it changes while a recovered password is being saved", async () => { + const passwordSave = deferred(); + const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1"); + const saveConnections = vi + .fn() + .mockImplementationOnce(() => passwordSave.promise) + .mockResolvedValue(undefined); + installApiMocks({ connectDb, saveConnections }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "old-endpoint-password", rememberPassword: true }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ + id: "mysql-1", + name: "Synced MySQL", + db_type: "mysql", + host: "old.example.com", + port: 3306, + username: "root", + save_password: true, + password: "", + }); + store.connections = [connection]; + + const connecting = store.connect(connection); + await vi.waitFor(() => expect(saveConnections).toHaveBeenCalledTimes(1)); + await store.updateConnection({ ...connection, host: "new.example.com" }); + passwordSave.resolve(); + await connecting; + + expect(saveConnections).toHaveBeenCalledTimes(3); + expect(saveConnections).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "mysql-1", + host: "new.example.com", + password: "", + }), + ]), + ); + expect(store.getConfig("mysql-1")).toEqual(expect.objectContaining({ host: "new.example.com", password: "" })); + }); + + it("keeps a successful connection when remembering the recovered password fails", async () => { + const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1"); + const saveConnections = vi.fn().mockRejectedValue(new Error("disk full")); + installApiMocks({ connectDb, saveConnections }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "typed-pw", rememberPassword: true }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ + id: "mysql-1", + name: "Synced MySQL", + db_type: "mysql", + port: 3306, + username: "root", + save_password: true, + password: "", + }); + store.connections = [connection]; + + await expect(store.connect(connection)).resolves.toBe("mysql-1"); + + expect(store.connectedIds.has("mysql-1")).toBe(true); + expect(store.activeConnectionId).toBe("mysql-1"); + expect(store.getConfig("mysql-1")?.password).toBe(""); + expect(store.connectionErrors["mysql-1"]).toContain("Connected, but DBX could not remember the password"); + expect(store.connectionErrors["mysql-1"]).toContain("disk full"); + }); + + it("keeps ensureConnected successful when remembering the recovered password fails", async () => { + const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1"); + const saveConnections = vi.fn().mockRejectedValue(new Error("disk full")); + installApiMocks({ connectDb, saveConnections }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "typed-pw", rememberPassword: true }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ + id: "mysql-1", + name: "Synced MySQL", + db_type: "mysql", + port: 3306, + username: "root", + save_password: true, + password: "", + }); + store.connections = [connection]; + + await expect(store.ensureConnected("mysql-1")).resolves.toBeUndefined(); + + expect(store.connectedIds.has("mysql-1")).toBe(true); + expect(store.getConfig("mysql-1")?.password).toBe(""); + expect(store.connectionErrors["mysql-1"]).toContain("Connected, but DBX could not remember the password"); + expect(store.connectionErrors["mysql-1"]).toContain("disk full"); + }); }); diff --git a/apps/desktop/src/stores/connectionPasswordPromptStore.ts b/apps/desktop/src/stores/connectionPasswordPromptStore.ts index 6aae800971..a26741dd0f 100644 --- a/apps/desktop/src/stores/connectionPasswordPromptStore.ts +++ b/apps/desktop/src/stores/connectionPasswordPromptStore.ts @@ -6,25 +6,30 @@ export interface ConnectionPasswordPromptRequest { connectionName: string; } +export interface ConnectionPasswordPromptResult { + password: string; + rememberPassword: boolean; +} + interface QueuedPasswordPrompt { request: ConnectionPasswordPromptRequest; - resolve: (password: string | null) => void; + resolve: (result: ConnectionPasswordPromptResult | null) => void; } /** - * Coordinates the single "enter password for a connection that is set to not - * save it locally" dialog shared by every connect surface. Mirrors the - * production-safety confirmation store: a request is transient, never - * persisted, and concurrent connects are queued so each prompt is shown and - * answered exactly once. A `null` resolution means the user cancelled. + * Coordinates the shared password dialog. The store only returns the password + * and the user's remember preference; the connection flow decides whether to + * persist it after authentication succeeds. Concurrent requests are queued so + * each prompt is shown and answered exactly once. A `null` result means the + * user cancelled. */ export const useConnectionPasswordPromptStore = defineStore("connectionPasswordPrompt", () => { const pending = ref(); const queue: QueuedPasswordPrompt[] = []; - let resolvePending: ((password: string | null) => void) | undefined; + let resolvePending: ((result: ConnectionPasswordPromptResult | null) => void) | undefined; - function requestPassword(request: ConnectionPasswordPromptRequest): Promise { - return new Promise((resolve) => { + function requestPassword(request: ConnectionPasswordPromptRequest): Promise { + return new Promise((resolve) => { if (pending.value) { queue.push({ request, resolve }); return; @@ -33,23 +38,23 @@ export const useConnectionPasswordPromptStore = defineStore("connectionPasswordP }); } - function beginRequest(request: ConnectionPasswordPromptRequest, resolve: (password: string | null) => void) { + function beginRequest(request: ConnectionPasswordPromptRequest, resolve: (result: ConnectionPasswordPromptResult | null) => void) { pending.value = request; resolvePending = resolve; } - function settle(password: string | null) { + function settle(result: ConnectionPasswordPromptResult | null) { const resolve = resolvePending; resolvePending = undefined; pending.value = undefined; - resolve?.(password); + resolve?.(result); const next = queue.shift(); if (next) beginRequest(next.request, next.resolve); } - function submit(password: string) { - settle(password); + function submit(password: string, rememberPassword: boolean) { + settle({ password, rememberPassword }); } function cancel() { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 43ec4cf681..41d90fef26 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -114,7 +114,7 @@ import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; -import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; +import { appendConnectionErrorHints, isMysqlMissingPasswordFailure } from "@/lib/connection/connectionErrorHints"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; import { isXuguPublicSynonymScope, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; @@ -2878,37 +2878,73 @@ export const useConnectionStore = defineStore("connection", () => { } /** - * For a connection configured with `save_password === false` (password not - * persisted), prompt the user for the password and return a copy of the - * config carrying the typed value. The returned config is used only for the - * immediate `connectDb` call — it is never written back to the store, so the - * typed password is not persisted and the next connect prompts again. + * Prompt for a transient password when saving is disabled, or when a server + * confirms that a metadata-only synced connection sent no password. The + * password is used for the immediate `connectDb` call and is persisted only + * after a successful connection when the user selects "remember password". */ - async function ensureConnectionPassword(config: ConnectionConfig): Promise { - if (config.save_password !== false || config.password) return config; + async function ensureConnectionPassword(config: ConnectionConfig, force = false): Promise<{ config: ConnectionConfig; rememberPassword: boolean }> { + if (!force && (config.save_password !== false || config.password)) return { config, rememberPassword: false }; const { useConnectionPasswordPromptStore } = await import("@/stores/connectionPasswordPromptStore"); - const password = await useConnectionPasswordPromptStore().requestPassword({ + const result = await useConnectionPasswordPromptStore().requestPassword({ connectionId: config.id, connectionName: config.name, }); - if (!password) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE); - return { ...config, password }; + if (!result?.password) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE); + return { config: { ...config, password: result.password }, rememberPassword: result.rememberPassword }; + } + + async function persistRememberedConnectionPassword(config: ConnectionConfig, rememberPassword: boolean, expectedConfigFingerprint: string): Promise { + if (!rememberPassword || !config.password) return; + const index = connections.value.findIndex((connection) => connection.id === config.id); + if (index < 0) return; + if (connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return; + const nextConnections = [...connections.value]; + nextConnections[index] = { ...nextConnections[index], password: config.password, save_password: true }; + await persistConnections(nextConnections); + if (connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) { + await persistConnections(); + return; + } + connections.value = nextConnections; + rebuildTreeNodes(); + } + + async function connectDbWithMissingPasswordRetry(config: ConnectionConfig, localAttempt: number): Promise<{ config: ConnectionConfig; id: string; rememberPassword: boolean }> { + try { + const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); + return { config, id, rememberPassword: false }; + } catch (error) { + if (!isMysqlMissingPasswordFailure(config, connectionErrorMessage(error))) throw error; + const prompted = await ensureConnectionPassword(config, true); + config = prompted.config; + ensureLocalConnectionAttemptActive(config.id, localAttempt); + const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); + return { config, id, rememberPassword: prompted.rememberPassword }; + } } async function connect(config: ConnectionConfig) { config = normalizeConnection(config); + const expectedConfigFingerprint = connectionConfigFingerprint(getConfig(config.id) ?? config); if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id); const localAttempt = beginLocalConnectionAttempt(config.id); try { + let rememberPassword = false; if (config.save_password === false && !config.password) { - config = await ensureConnectionPassword(config); + const prompted = await ensureConnectionPassword(config); + config = prompted.config; + rememberPassword = prompted.rememberPassword; } await beforeConnectHandler?.(config); if (config.db_type === "sqlserver") { await ensureSqlServerLegacyCompatibilityComponentInstalled(config); } ensureLocalConnectionAttemptActive(config.id, localAttempt); - const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); + const connection = await connectDbWithMissingPasswordRetry(config, localAttempt); + config = connection.config; + rememberPassword ||= connection.rememberPassword; + const id = connection.id; await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id); await syncMongoLegacyDriverFallback(id, config); await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id); @@ -2942,6 +2978,11 @@ export const useConnectionStore = defineStore("connection", () => { comment: config.note || null, }); } + try { + await persistRememberedConnectionPassword(config, rememberPassword, expectedConfigFingerprint); + } catch (error) { + setConnectionError(id, i18n.global.t("connection.rememberPasswordSaveFailed", { message: connectionErrorMessage(error) })); + } return id; } catch (e) { if (isCancelledLocalConnectionAttempt(config.id, localAttempt)) { @@ -3077,6 +3118,7 @@ export const useConnectionStore = defineStore("connection", () => { recordConnectionError(connectionId, error); throw error; } + const expectedConfigFingerprint = connectionConfigFingerprint(config); if (getBlockingDisconnectInFlight(connectionId)) await waitForBlockingDisconnectInFlight(connectionId); const existingConnect = connectInFlight.get(connectionId); if (existingConnect) { @@ -3086,18 +3128,24 @@ export const useConnectionStore = defineStore("connection", () => { } const localAttempt = beginLocalConnectionAttempt(connectionId); const connectPromise = (async () => { + let rememberPassword = false; // Fast-path the common case (password saved or no password needed) so the // in-flight dedup above keeps its exact microtask cadence; only await the // interactive prompt when the connection actually needs a typed password. if (config.save_password === false && !config.password) { - config = await ensureConnectionPassword(config); + const prompted = await ensureConnectionPassword(config); + config = prompted.config; + rememberPassword = prompted.rememberPassword; } await beforeConnectHandler?.(config); if (config.db_type === "sqlserver") { await ensureSqlServerLegacyCompatibilityComponentInstalled(config); } ensureLocalConnectionAttemptActive(connectionId, localAttempt); - const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); + const connection = await connectDbWithMissingPasswordRetry(config, localAttempt); + config = connection.config; + rememberPassword ||= connection.rememberPassword; + const id = connection.id; await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id); await syncMongoLegacyDriverFallback(connectionId, config); await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id); @@ -3107,6 +3155,11 @@ export const useConnectionStore = defineStore("connection", () => { markSuccessfulLocalConnectionAttempt(connectionId, localAttempt); markConnectionHealthChecked(connectionId); clearConnectionError(connectionId); + try { + await persistRememberedConnectionPassword(config, rememberPassword, expectedConfigFingerprint); + } catch (error) { + setConnectionError(connectionId, i18n.global.t("connection.rememberPasswordSaveFailed", { message: connectionErrorMessage(error) })); + } })(); connectInFlight.set(connectionId, connectPromise); try {