diff --git a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts index 63063e8ee..b20067f04 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts @@ -619,4 +619,140 @@ describe("connectionStore plugin password prompt", () => { await expect(store.connect(connection)).rejects.toThrow(CONNECTION_PASSWORD_REQUIRED_MESSAGE); }); + + it("reconnects an open plugin with its saved credential after runtime config changes", async () => { + const connectDb = vi.fn().mockResolvedValue("ssh-1"); + const savedPassword = ["stored", "pw"].join("-"); + installApiMocks({ connectDb, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = pluginConnection({ save_password: true, password: savedPassword, external_config: { authentication: "password" } }); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await store.updateConnection({ ...connection, host: "new.example.com" }); + + expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", host: "new.example.com", password: savedPassword }), expect.any(Number)); + expect(requestPassword).not.toHaveBeenCalled(); + expect(store.connectedIds.has(connection.id)).toBe(true); + }); + + it("reconnects an open plugin through its session credential without prompting", async () => { + const connectDb = vi.fn().mockResolvedValue("ssh-1"); + const sessionCredentialStatus = vi.fn().mockResolvedValue(true); + installApiMocks({ connectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = pluginConnection({ external_config: { authentication: "password" } }); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await store.updateConnection({ ...connection, host: "new.example.com" }); + + expect(sessionCredentialStatus).toHaveBeenCalledWith(connection.id); + expect(requestPassword).not.toHaveBeenCalled(); + expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", host: "new.example.com", password: "" }), expect.any(Number)); + expect(store.connectedIds.has(connection.id)).toBe(true); + }); + + it("disconnects an open plugin instead of prompting when no credential is available", async () => { + const connectDb = vi.fn().mockResolvedValue("ssh-1"); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + const sessionCredentialStatus = vi.fn().mockResolvedValue(false); + installApiMocks({ connectDb, disconnectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = pluginConnection({ external_config: { authentication: "password" } }); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await store.updateConnection({ ...connection, host: "new.example.com" }); + + expect(requestPassword).not.toHaveBeenCalled(); + expect(connectDb).not.toHaveBeenCalled(); + expect(disconnectDb).toHaveBeenCalledWith(connection.id, undefined); + expect(store.connectedIds.has(connection.id)).toBe(false); + expect(store.connectionErrors[connection.id]).toContain("Reconnect manually"); + expect(store.getConfig(connection.id)?.host).toBe("new.example.com"); + }); + + it("does not connect a plugin that was offline when its config was saved", async () => { + const connectDb = vi.fn().mockResolvedValue("ssh-1"); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + const sessionCredentialStatus = vi.fn().mockResolvedValue(true); + installApiMocks({ connectDb, disconnectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = pluginConnection({ external_config: { authentication: "password" } }); + store.connections = [connection]; + + await store.updateConnection({ ...connection, host: "new.example.com" }); + + expect(connectDb).not.toHaveBeenCalled(); + expect(disconnectDb).not.toHaveBeenCalled(); + expect(sessionCredentialStatus).not.toHaveBeenCalled(); + expect(requestPassword).not.toHaveBeenCalled(); + expect(store.connectedIds.has(connection.id)).toBe(false); + }); + + it("reconnects an open plugin when its display name changes", async () => { + const connectDb = vi.fn().mockResolvedValue("ssh-1"); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + installApiMocks({ connectDb, disconnectDb, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = pluginConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await store.updateConnection({ ...connection, name: "Renamed SSH" }); + + expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: connection.id, name: "Renamed SSH" }), expect.any(Number)); + expect(disconnectDb).not.toHaveBeenCalled(); + expect(store.connectedIds.has(connection.id)).toBe(true); + }); + + it("preserves native connection invalidation without reconnecting it", async () => { + const connectDb = vi.fn().mockResolvedValue("pg-1"); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + installApiMocks({ connectDb, disconnectDb }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ password: "" }); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await store.updateConnection({ ...connection, host: "new.example.com" }); + + expect(connectDb).not.toHaveBeenCalled(); + expect(disconnectDb).not.toHaveBeenCalled(); + expect(store.connectedIds.has(connection.id)).toBe(false); + }); + + it("keeps saved plugin settings and an offline error when automatic reconnect fails", async () => { + const connectDb = vi.fn().mockRejectedValue(new Error("sidecar rejected config")); + const saveConnections = vi.fn().mockResolvedValue(undefined); + const savedPassword = ["stored", "pw"].join("-"); + installApiMocks({ connectDb, saveConnections, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = pluginConnection({ save_password: true, password: savedPassword, external_config: { authentication: "password" } }); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await expect(store.updateConnection({ ...connection, host: "new.example.com" })).resolves.toBeUndefined(); + + expect(saveConnections).toHaveBeenCalledWith([expect.objectContaining({ id: connection.id, host: "new.example.com" })]); + expect(store.getConfig(connection.id)?.host).toBe("new.example.com"); + expect(store.connectedIds.has(connection.id)).toBe(false); + expect(store.connectionErrors[connection.id]).toContain("settings were saved"); + expect(store.connectionErrors[connection.id]).toContain("sidecar rejected config"); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index a6f8afa4c..a5b693193 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -432,6 +432,7 @@ type BeforeConnectHandler = (config: ConnectionConfig) => Promise; export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled"; /** Thrown when a no-save-password connection is connected without a typed password. */ export const CONNECTION_PASSWORD_REQUIRED_MESSAGE = "Password is required for this connection"; +const PLUGIN_CONFIG_RECONNECT_PASSWORD_MESSAGE = "Plugin settings were saved, but the connection was disconnected because its password is not available. Reconnect manually to apply the new settings."; function metadataDriverProfile(config?: ConnectionConfig): string | undefined { return config?.driver_profile || config?.db_type; @@ -4029,7 +4030,9 @@ export const useConnectionStore = defineStore("connection", () => { if (config.save_password === false) config.password = ""; const idx = connections.value.findIndex((c) => c.id === config.id); if (idx < 0) return; - const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config); + const previousConfig = normalizeConnection(connections.value[idx]); + const runtimeConfigChanged = connectionConfigFingerprint(previousConfig) !== connectionConfigFingerprint(config); + const shouldReconnectPlugin = runtimeConfigChanged && previousConfig.db_type === "plugin" && config.db_type === "plugin" && connectedIds.value.has(config.id); const nextConnections = [...connections.value]; nextConnections[idx] = config; await persistTimeoutInheritance(config.id, config.connect_timeout_inherit === true, config.query_timeout_inherit === true); @@ -4052,6 +4055,7 @@ export const useConnectionStore = defineStore("connection", () => { if (node?.isExpanded) { await reloadConnectionDatabaseChildren(config.id); } + if (shouldReconnectPlugin) await reconnectPluginConnectionAfterConfigUpdate(config); } async function updateRedisKeyGrouping(connectionId: string, grouping: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping) { @@ -4767,7 +4771,7 @@ export const useConnectionStore = defineStore("connection", () => { invalidateConnectionMetadataLifetime(connectionId, database); } - async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean } = {}) { + async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean; allowPasswordPrompt?: boolean } = {}) { if (!options.forceReconnect && connectedIds.value.has(connectionId)) { // Pure navigation can safely trust the existing connected state. Its // destination will perform the real API request, while blocking here on @@ -4824,6 +4828,7 @@ export const useConnectionStore = defineStore("connection", () => { // in-flight dedup above keeps its exact microtask cadence; only await the // interactive prompt when the connection actually needs a typed password. if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) { + if (options.allowPasswordPrompt === false) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE); const prompted = await ensureConnectionPassword(config); config = prompted.config; rememberPassword = prompted.rememberPassword; @@ -4886,6 +4891,31 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function canReconnectPluginConnectionWithoutPrompt(config: ConnectionConfig): Promise { + if (!connectionNeedsPasswordPrompt(config)) return true; + if (!(await pluginConnectionPasswordPromptNeeded(config))) return true; + return hasSessionCredential(config.id); + } + + async function reconnectPluginConnectionAfterConfigUpdate(config: ConnectionConfig): Promise { + if (!(await canReconnectPluginConnectionWithoutPrompt(config))) { + try { + await startDisconnectRequest(config.id); + setConnectionError(config.id, PLUGIN_CONFIG_RECONNECT_PASSWORD_MESSAGE); + } catch (error) { + setConnectionError(config.id, `Plugin settings were saved, but DBX could not disconnect the stale plugin connection: ${connectionErrorMessage(error)}. Reconnect manually to apply the new settings.`); + } + return; + } + + try { + await ensureConnected(config.id, { activate: false, forceReconnect: true, allowPasswordPrompt: false }); + } catch (error) { + connectedIds.value.delete(config.id); + setConnectionError(config.id, `Plugin settings were saved, but automatic reconnection failed: ${connectionErrorMessage(error)}`); + } + } + /** * Re-push an already-open plugin connection's config (credentials included) * to its sidecar through the same connection/connect path used when opening @@ -4908,18 +4938,15 @@ export const useConnectionStore = defineStore("connection", () => { // plugin's first `ready`. The 2s in-memory TTL dies with the frontend, so // every realistic reload path still re-pushes. if (hasRecentConnectionHealthCheck(connectionId)) return; - if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) return; - await ensureConnected(connectionId, { activate: false, forceReconnect: true }); + if (!(await canReconnectPluginConnectionWithoutPrompt(config))) return; + await ensureConnected(connectionId, { activate: false, forceReconnect: true, allowPasswordPrompt: false }); } /** * Explicit, user-triggered reconnect of a plugin connection (the plugin's own * "reconnect" button). Unlike repushPluginConnection — the silent background * heal — this runs the full connect flow and MAY show the interactive - * password prompt, which is appropriate for a deliberate user action. Editing - * a connection drops it from connectedIds without notifying plugins, so the - * sidecar's config goes stale; this is the plugin's way to request a fresh - * connection/connect with the updated config. + * password prompt, which is appropriate for a deliberate user action. */ async function reopenPluginConnection(connectionId: string, pluginId: string): Promise { const config = getConfig(connectionId);