fix(connection): improve synced password recovery

This commit is contained in:
大熊 authored and GitHub committed 2026-08-13 20:34:54 +08:00
1 parent de72675b67
commit d64908c596
14 files changed
+334 -39

No files matched your search

@@ -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() {
<div class="py-1">
<PasswordInput v-model="password" autofocus :placeholder="t('connection.promptPasswordPlaceholder')" :disabled="resolving" @keydown.enter.prevent="submit" />
<label class="mt-3 flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input v-model="rememberPassword" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :disabled="resolving" />
<span>{{ t("connection.promptRememberPassword") }}</span>
</label>
</div>
<DialogFooter>
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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: "必須、既存のデータベースを入力",
+4 -1
View File
@@ -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: "필수, 기존 데이터베이스 입력",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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: "必填,请填写已存在的数据库",
+4 -1
View File
@@ -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: "必填,請填寫已存在的資料庫",
@@ -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);
@@ -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");
@@ -12,6 +12,16 @@ function installLocalStorage() {
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function postgresConnection(overrides: Partial<ConnectionConfig> = {}): 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<string>();
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<void>();
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");
});
});
@@ -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<ConnectionPasswordPromptRequest>();
const queue: QueuedPasswordPrompt[] = [];
let resolvePending: ((password: string | null) => void) | undefined;
let resolvePending: ((result: ConnectionPasswordPromptResult | null) => void) | undefined;
function requestPassword(request: ConnectionPasswordPromptRequest): Promise<string | null> {
return new Promise<string | null>((resolve) => {
function requestPassword(request: ConnectionPasswordPromptRequest): Promise<ConnectionPasswordPromptResult | null> {
return new Promise<ConnectionPasswordPromptResult | null>((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() {
+68 -15
View File
@@ -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<ConnectionConfig> {
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<void> {
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 {