fix(connection): improve synced password recovery
This commit is contained in:
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "必須、既存のデータベースを入力",
|
||||
|
||||
@@ -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: "필수, 기존 데이터베이스 입력",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "必填,请填写已存在的数据库",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user