From d64908c5963c5177f444bb9d2e6b79d00b9b1bfe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A4=A7=E7=86=8A?=
<49681654+xzxlove@users.noreply.github.com>
Date: Thu, 13 Aug 2026 20:34:54 +0800
Subject: [PATCH] fix(connection): improve synced password recovery
---
.../ConnectionPasswordPromptDialog.vue | 8 +-
apps/desktop/src/i18n/locales/en.ts | 5 +-
apps/desktop/src/i18n/locales/es.ts | 5 +-
apps/desktop/src/i18n/locales/it.ts | 5 +-
apps/desktop/src/i18n/locales/ja.ts | 5 +-
apps/desktop/src/i18n/locales/ko.ts | 5 +-
apps/desktop/src/i18n/locales/pt-BR.ts | 5 +-
apps/desktop/src/i18n/locales/zh-CN.ts | 5 +-
apps/desktop/src/i18n/locales/zh-TW.ts | 5 +-
.../__tests__/connectionErrorHints.spec.ts | 15 ++
.../lib/connection/connectionErrorHints.ts | 12 ++
.../connectionStore.savePassword.spec.ts | 182 +++++++++++++++++-
.../stores/connectionPasswordPromptStore.ts | 33 ++--
apps/desktop/src/stores/connectionStore.ts | 83 ++++++--
14 files changed, 334 insertions(+), 39 deletions(-)
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 {