From 572b6289552d632730d6b63bcf94d325b50dd2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 31 Mar 2026 15:17:03 +0700 Subject: [PATCH 1/3] feat: add option to prompt for database password on every connection (#534) --- CHANGELOG.md | 1 + TablePro/Core/Database/DatabaseDriver.swift | 10 ++- TablePro/Core/Database/DatabaseManager.swift | 68 +++++++++++++-- TablePro/Core/Storage/ConnectionStorage.swift | 4 +- .../Utilities/UI/PasswordPromptHelper.swift | 37 +++++++++ .../Models/Connection/ConnectionSession.swift | 3 + .../Connection/DatabaseConnection.swift | 5 ++ TablePro/Resources/Localizable.xcstrings | 83 +++++++++++++++++++ .../Views/Connection/ConnectionFormView.swift | 30 +++++-- .../Views/Connection/ConnectionSSLView.swift | 1 + .../Connection/PasswordPromptToggle.swift | 43 ++++++++++ 11 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 TablePro/Core/Utilities/UI/PasswordPromptHelper.swift create mode 100644 TablePro/Views/Connection/PasswordPromptToggle.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 037221cab..166356a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Option to prompt for database password on every connection instead of saving to Keychain - Autocompletion for filter fields: column names and SQL keywords suggested as you type (Raw SQL and Value fields) - Multi-line support for Raw SQL filter field (Option+Enter for newline) - Visual Create Table UI with multi-database support (sidebar → "Create New Table...") diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index dddcb78d5..b6dd0e4df 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -317,7 +317,10 @@ extension DatabaseDriver { enum DatabaseDriverFactory { private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseDriverFactory") - static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver { + static func createDriver( + for connection: DatabaseConnection, + passwordOverride: String? = nil + ) throws -> DatabaseDriver { let pluginId = connection.type.pluginTypeId // If the plugin isn't registered yet and background loading hasn't finished, // fall back to synchronous loading for this critical code path. @@ -338,7 +341,7 @@ enum DatabaseDriverFactory { host: connection.host, port: connection.port, username: connection.username, - password: resolvePassword(for: connection), + password: resolvePassword(for: connection, override: passwordOverride), database: connection.database, additionalFields: buildAdditionalFields(for: connection, plugin: plugin) ) @@ -346,7 +349,8 @@ enum DatabaseDriverFactory { return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) } - private static func resolvePassword(for connection: DatabaseConnection) -> String { + private static func resolvePassword(for connection: DatabaseConnection, override: String? = nil) -> String { + if let override { return override } if connection.usePgpass { let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host let pgpassPort = connection.additionalFields["pgpassOriginalPort"] diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index f9bb5bdd2..9927cb44a 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -133,10 +133,32 @@ final class DatabaseManager { } } + // Resolve password override for prompt-for-password connections + var passwordOverride: String? + if connection.promptForPassword { + if let cached = activeSessions[connection.id]?.cachedPassword { + passwordOverride = cached + } else { + let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly + guard let prompted = PasswordPromptHelper.prompt( + connectionName: connection.name, + isAPIToken: isApiOnly + ) else { + removeSessionEntry(for: connection.id) + currentSessionId = nil + throw CancellationError() + } + passwordOverride = prompted + } + } + // Create appropriate driver with effective connection let driver: DatabaseDriver do { - driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection) + driver = try DatabaseDriverFactory.createDriver( + for: effectiveConnection, + passwordOverride: passwordOverride + ) } catch { // Close tunnel if SSH was established if connection.sshConfig.enabled { @@ -217,7 +239,9 @@ final class DatabaseManager { session.driver = driver session.status = driver.status session.effectiveConnection = effectiveConnection - + if let passwordOverride { + session.cachedPassword = passwordOverride + } setSession(session, for: connection.id) } @@ -418,9 +442,11 @@ final class DatabaseManager { } /// Test a connection without keeping it open - func testConnection(_ connection: DatabaseConnection, sshPassword: String? = nil) async throws - -> Bool - { + func testConnection( + _ connection: DatabaseConnection, + sshPassword: String? = nil, + passwordOverride: String? = nil + ) async throws -> Bool { // Build effective connection (creates SSH tunnel if needed) let testConnection = try await buildEffectiveConnection( for: connection, @@ -429,7 +455,10 @@ final class DatabaseManager { let result: Bool do { - let driver = try DatabaseDriverFactory.createDriver(for: testConnection) + let driver = try DatabaseDriverFactory.createDriver( + for: testConnection, + passwordOverride: passwordOverride + ) result = try await driver.testConnection() } catch { if connection.sshConfig.enabled { @@ -643,7 +672,10 @@ final class DatabaseManager { // Use effective connection (tunneled) if available, otherwise original let connectionForDriver = session.effectiveConnection ?? session.connection - let driver = try DatabaseDriverFactory.createDriver(for: connectionForDriver) + let driver = try DatabaseDriverFactory.createDriver( + for: connectionForDriver, + passwordOverride: session.cachedPassword + ) try await driver.connect() // Apply timeout @@ -712,8 +744,25 @@ final class DatabaseManager { // Recreate SSH tunnel if needed and build effective connection let effectiveConnection = try await buildEffectiveConnection(for: session.connection) + // Resolve password for prompt-for-password connections + var passwordOverride = activeSessions[sessionId]?.cachedPassword + if session.connection.promptForPassword && passwordOverride == nil { + let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly + guard let prompted = PasswordPromptHelper.prompt( + connectionName: session.connection.name, + isAPIToken: isApiOnly + ) else { + updateSession(sessionId) { $0.status = .disconnected } + return + } + passwordOverride = prompted + } + // Create new driver and connect - let driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection) + let driver = try DatabaseDriverFactory.createDriver( + for: effectiveConnection, + passwordOverride: passwordOverride + ) try await driver.connect() // Apply timeout @@ -750,6 +799,9 @@ final class DatabaseManager { session.driver = driver session.status = .connected session.effectiveConnection = effectiveConnection + if let passwordOverride { + session.cachedPassword = passwordOverride + } } // Restart health monitoring if the plugin supports it diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index a2c465b18..5012dfcb1 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -163,8 +163,8 @@ final class ConnectionStorage { saveConnections(connections) SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString) - // Copy all passwords from source to duplicate - if let password = loadPassword(for: connection.id) { + // Copy all passwords from source to duplicate (skip DB password in prompt mode) + if !connection.promptForPassword, let password = loadPassword(for: connection.id) { savePassword(password, for: newId) } if let sshPassword = loadSSHPassword(for: connection.id) { diff --git a/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift new file mode 100644 index 000000000..8fd12867e --- /dev/null +++ b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift @@ -0,0 +1,37 @@ +// +// PasswordPromptHelper.swift +// TablePro +// +// Prompts the user for a database password via a native modal alert. +// + +import AppKit + +enum PasswordPromptHelper { + /// Presents a modal alert with a secure text field to collect a password or API token. + /// Returns the entered value, or `nil` if the user cancels or enters an empty string. + @MainActor + static func prompt(connectionName: String, isAPIToken: Bool = false) -> String? { + let alert = NSAlert() + alert.messageText = isAPIToken + ? String(localized: "API Token Required") + : String(localized: "Password Required") + alert.informativeText = String( + format: String(localized: "Enter the %@ for \"%@\""), + isAPIToken ? String(localized: "API token") : String(localized: "password"), + connectionName + ) + alert.addButton(withTitle: String(localized: "Connect")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let input = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.placeholderString = isAPIToken + ? String(localized: "API Token") : String(localized: "Password") + alert.accessoryView = input + alert.window.initialFirstResponder = input + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let value = input.stringValue + return value.isEmpty ? nil : value + } +} diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index 42d31787b..f9fd1463a 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -26,6 +26,9 @@ struct ConnectionSession: Identifiable { var currentSchema: String? var currentDatabase: String? + /// In-memory password for prompt-for-password connections. Never persisted to disk. + var cachedPassword: String? + var activeDatabase: String { currentDatabase ?? connection.database } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index b603b9c48..0faa4a3f2 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -429,6 +429,11 @@ struct DatabaseConnection: Identifiable, Hashable { set { additionalFields["usePgpass"] = newValue ? "true" : "" } } + var promptForPassword: Bool { + get { additionalFields["promptForPassword"] == "true" } + set { additionalFields["promptForPassword"] = newValue ? "true" : "" } + } + var preConnectScript: String? { get { additionalFields["preConnectScript"]?.nilIfEmpty } set { additionalFields["preConnectScript"] = newValue ?? "" } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 5f8373995..d7944df2c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3386,6 +3386,9 @@ } } } + }, + "AI-Powered Assistant" : { + }, "AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss." : { "localizations" : { @@ -5153,6 +5156,9 @@ } } } + }, + "Browse, edit, and manage your data with ease" : { + }, "Browse..." : { "localizations" : { @@ -6807,6 +6813,9 @@ } } } + }, + "Comma-separated values. Compatible with Excel and most tools." : { + }, "Command Preview" : { "extractionState" : "stale", @@ -7047,6 +7056,9 @@ } } } + }, + "Connect to popular databases with full feature support" : { + }, "Connect to the internet to verify your license." : { "localizations" : { @@ -7905,6 +7917,9 @@ } } } + }, + "Copy error message" : { + }, "Copy Name" : { "localizations" : { @@ -11966,6 +11981,9 @@ } } } + }, + "Excel spreadsheet with formatting support." : { + }, "Execute" : { "localizations" : { @@ -12960,6 +12978,7 @@ } }, "Failed to decompress file: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13273,6 +13292,7 @@ } }, "Failed to load preview using encoding: %@. Try selecting a different text encoding." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13295,6 +13315,7 @@ } }, "Failed to load preview: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -14322,6 +14343,9 @@ } } } + }, + "Format SQL" : { + }, "Format:" : { "localizations" : { @@ -14498,6 +14522,9 @@ } } } + }, + "Get intelligent SQL suggestions and query assistance" : { + }, "Get Started" : { "localizations" : { @@ -16417,6 +16444,9 @@ } } } + }, + "Interactive Data Grid" : { + }, "Interface" : { "localizations" : { @@ -18745,6 +18775,9 @@ } } } + }, + "MongoDB query language. Use to import into MongoDB." : { + }, "Move Down" : { "extractionState" : "stale", @@ -18999,6 +19032,9 @@ } } } + }, + "MySQL, PostgreSQL & SQLite" : { + }, "Name" : { "localizations" : { @@ -20712,6 +20748,9 @@ } } } + }, + "No rows returned" : { + }, "No saved connection named \"%@\"." : { "localizations" : { @@ -24272,6 +24311,7 @@ } }, "Query executing..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -25583,6 +25623,15 @@ } } } + }, + "Reset" : { + + }, + "Reset All Settings" : { + + }, + "Reset All Settings to Defaults" : { + }, "Reset to Defaults" : { "localizations" : { @@ -27176,6 +27225,9 @@ } } } + }, + "Secure Connections" : { + }, "SELECT * FROM users WHERE id = 1;" : { "extractionState" : "stale", @@ -28543,6 +28595,9 @@ } } } + }, + "Smart SQL Editor" : { + }, "Smooth" : { "localizations" : { @@ -28587,6 +28642,9 @@ } } } + }, + "Some columns in this preset don't exist in the current table" : { + }, "Something went wrong (error %lld). Try again in a moment." : { "localizations" : { @@ -28854,6 +28912,9 @@ } } } + }, + "SQL INSERT statements. Use to recreate data in another database." : { + }, "SQL Preview" : { "localizations" : { @@ -29243,6 +29304,9 @@ } } } + }, + "SSH tunneling and SSL/TLS encryption support" : { + }, "SSH User" : { "localizations" : { @@ -29742,6 +29806,9 @@ } } } + }, + "Structured data format. Ideal for APIs and web applications." : { + }, "Success" : { "localizations" : { @@ -29950,6 +30017,7 @@ } }, "Sync" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -29970,6 +30038,9 @@ } } } + }, + "Sync (Pro)" : { + }, "Sync Categories" : { "localizations" : { @@ -30288,6 +30359,9 @@ } } } + }, + "Syntax highlighting, autocomplete, and multi-tab editing" : { + }, "System" : { "localizations" : { @@ -31806,6 +31880,9 @@ } } } + }, + "This will reset all settings across every section to their default values." : { + }, "Tier:" : { "localizations" : { @@ -34744,6 +34821,12 @@ } } } + }, + "Zoom In" : { + + }, + "Zoom Out" : { + } }, "version" : "1.0" diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 4c3e708a5..357009d45 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -68,6 +68,7 @@ struct ConnectionFormView: View { @State private var connectionURL: String = "" @State private var urlParseError: String? @State private var showURLImport = false + @State private var promptForPassword: Bool = false @State private var hasLoadedData = false // SSH Configuration @@ -202,6 +203,7 @@ struct ConnectionFormView: View { connectAfterInstall(connection) } .onChange(of: pgpassTrigger) { _, _ in updatePgpassStatus() } + .onChange(of: usePgpass) { _, newValue in if newValue { promptForPassword = false } } } // MARK: - Tab Picker Helpers @@ -419,10 +421,11 @@ struct ConnectionFormView: View { } } if !hidePasswordField { - let isApiOnly = PluginManager.shared.connectionMode(for: type) == .apiOnly - SecureField( - isApiOnly ? String(localized: "API Token") : String(localized: "Password"), - text: $password + PasswordPromptToggle( + type: type, + promptForPassword: $promptForPassword, + password: $password, + additionalFieldValues: $additionalFieldValues ) } if additionalFieldValues["usePgpass"] == "true" { @@ -666,7 +669,7 @@ struct ConnectionFormView: View { .filter(\.isRequired) .allSatisfy { !(additionalFieldValues[$0.id] ?? "").isEmpty } basicValid = basicValid && hasRequiredFields - if !hidePasswordField { + if !hidePasswordField && !promptForPassword { basicValid = basicValid && !password.isEmpty } // Generic: validate required visible fields @@ -763,6 +766,7 @@ struct ConnectionFormView: View { // Load additional fields from connection additionalFieldValues = existing.additionalFields + promptForPassword = existing.promptForPassword // Migrate legacy redisDatabase to additionalFields if additionalFieldValues["redisDatabase"] == nil, @@ -853,6 +857,8 @@ struct ConnectionFormView: View { finalAdditionalFields.removeValue(forKey: "preConnectScript") } + finalAdditionalFields["promptForPassword"] = promptForPassword ? "true" : nil + let secureFields = PluginManager.shared.additionalConnectionFields(for: type).filter(\.isSecure) for field in secureFields { if let value = finalAdditionalFields[field.id], !value.isEmpty { @@ -886,7 +892,9 @@ struct ConnectionFormView: View { ) // Save passwords to Keychain - if !password.isEmpty { + if promptForPassword { + storage.deletePassword(for: connectionToSave.id) + } else if !password.isEmpty { storage.savePassword(password, for: connectionToSave.id) } // Only save SSH secrets per-connection when using inline config (not a profile) @@ -1064,8 +1072,8 @@ struct ConnectionFormView: View { Task { do { - // Save passwords temporarily for test - if !password.isEmpty { + // Save passwords temporarily for test (skip when prompt mode is active) + if !password.isEmpty && !promptForPassword { ConnectionStorage.shared.savePassword(password, for: testConn.id) } // Only write inline SSH secrets when not using a profile @@ -1093,8 +1101,12 @@ struct ConnectionFormView: View { } let sshPasswordForTest = sshProfileId == nil ? sshPassword : nil + let testPasswordOverride = promptForPassword && !password.isEmpty ? password : nil let success = try await DatabaseManager.shared.testConnection( - testConn, sshPassword: sshPasswordForTest) + testConn, + sshPassword: sshPasswordForTest, + passwordOverride: testPasswordOverride + ) cleanupTestSecrets(for: testConn.id) await MainActor.run { isTesting = false diff --git a/TablePro/Views/Connection/ConnectionSSLView.swift b/TablePro/Views/Connection/ConnectionSSLView.swift index e095085fd..32dae830c 100644 --- a/TablePro/Views/Connection/ConnectionSSLView.swift +++ b/TablePro/Views/Connection/ConnectionSSLView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UniformTypeIdentifiers struct ConnectionSSLView: View { @Binding var sslMode: SSLMode diff --git a/TablePro/Views/Connection/PasswordPromptToggle.swift b/TablePro/Views/Connection/PasswordPromptToggle.swift new file mode 100644 index 000000000..b9271b26c --- /dev/null +++ b/TablePro/Views/Connection/PasswordPromptToggle.swift @@ -0,0 +1,43 @@ +// +// PasswordPromptToggle.swift +// TablePro +// +// Toggle + conditional SecureField for the "ask for password on every connection" option. +// + +import SwiftUI +import TableProPluginKit + +struct PasswordPromptToggle: View { + let type: DatabaseType + @Binding var promptForPassword: Bool + @Binding var password: String + @Binding var additionalFieldValues: [String: String] + + private var isApiOnly: Bool { + PluginManager.shared.connectionMode(for: type) == .apiOnly + } + + var body: some View { + Toggle( + isApiOnly + ? String(localized: "Ask for API token on every connection") + : String(localized: "Ask for password on every connection"), + isOn: $promptForPassword + ) + .onChange(of: promptForPassword) { _, newValue in + if newValue { + password = "" + if additionalFieldValues["usePgpass"] == "true" { + additionalFieldValues["usePgpass"] = "" + } + } + } + if !promptForPassword { + SecureField( + isApiOnly ? String(localized: "API Token") : String(localized: "Password"), + text: $password + ) + } + } +} From 3f68388d68c11beffe11113b883777a7bb2fc577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 31 Mar 2026 15:29:20 +0700 Subject: [PATCH 2/3] fix: handle password prompt cancellation gracefully, allow empty passwords --- TablePro/AppDelegate+ConnectionHandler.swift | 14 ++++++ TablePro/AppDelegate+WindowConfig.swift | 6 +++ .../Utilities/UI/PasswordPromptHelper.swift | 5 +-- TablePro/ViewModels/WelcomeViewModel.swift | 7 +++ .../Views/Connection/ConnectionFormView.swift | 45 +++++++++---------- 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 253fcba5d..c97224317 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -430,6 +430,20 @@ extension AppDelegate { // MARK: - Connection Failure func handleConnectionFailure(_ error: Error) async { + // User cancelled password prompt — clean up windows silently, no error dialog + if error is CancellationError { + for window in NSApp.windows where isMainWindow(window) { + let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains { + window.subtitle == $0.connection.name + || window.subtitle == "\($0.connection.name) — Preview" + } + if !hasActiveSession { window.close() } + } + if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) { + openWelcomeWindow() + } + return + } for window in NSApp.windows where isMainWindow(window) { let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains { window.subtitle == $0.connection.name diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 1ac8e40e6..6a0729f1b 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -339,6 +339,12 @@ extension AppDelegate { for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } + } catch is CancellationError { + // User cancelled password prompt at startup — return to welcome + for window in NSApp.windows where self.isMainWindow(window) { + window.close() + } + self.openWelcomeWindow() } catch { windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") diff --git a/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift index 8fd12867e..143671306 100644 --- a/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift +++ b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift @@ -9,7 +9,7 @@ import AppKit enum PasswordPromptHelper { /// Presents a modal alert with a secure text field to collect a password or API token. - /// Returns the entered value, or `nil` if the user cancels or enters an empty string. + /// Returns the entered value (may be empty for passwordless databases), or `nil` if the user cancels. @MainActor static func prompt(connectionName: String, isAPIToken: Bool = false) -> String? { let alert = NSAlert() @@ -31,7 +31,6 @@ enum PasswordPromptHelper { alert.window.initialFirstResponder = input guard alert.runModal() == .alertFirstButtonReturn else { return nil } - let value = input.stringValue - return value.isEmpty ? nil : value + return input.stringValue } } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 6738db9b4..a264523a6 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -215,6 +215,10 @@ final class WelcomeViewModel { Task { do { try await dbManager.connectToSession(connection) + } catch is CancellationError { + // User cancelled password prompt — return to welcome + NSApplication.shared.closeWindows(withId: "main") + self.openWindow?(id: "welcome") } catch { if case PluginError.pluginNotInstalled = error { Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") @@ -237,6 +241,9 @@ final class WelcomeViewModel { Task { do { try await dbManager.connectToSession(connection) + } catch is CancellationError { + NSApplication.shared.closeWindows(withId: "main") + self.openWindow?(id: "welcome") } catch { Self.logger.error( "Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)") diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 357009d45..320340165 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -956,24 +956,31 @@ struct ConnectionFormView: View { do { try await dbManager.connectToSession(connection) } catch { - if case PluginError.pluginNotInstalled = error { - Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") - handleMissingPlugin(connection: connection) - } else { - Self.logger.error( - "Failed to connect: \(error.localizedDescription, privacy: .public)") - NSApplication.shared.closeWindows(withId: "main") - openWindow(id: "welcome") - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: nil - ) - } + handleConnectError(error, connection: connection) } } } + private func handleConnectError(_ error: Error, connection: DatabaseConnection) { + if error is CancellationError { + NSApplication.shared.closeWindows(withId: "main") + openWindow(id: "welcome") + } else if case PluginError.pluginNotInstalled = error { + Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") + handleMissingPlugin(connection: connection) + } else { + Self.logger.error( + "Failed to connect: \(error.localizedDescription, privacy: .public)") + NSApplication.shared.closeWindows(withId: "main") + openWindow(id: "welcome") + AlertHelper.showErrorSheet( + title: String(localized: "Connection Failed"), + message: error.localizedDescription, + window: nil + ) + } + } + private func handleMissingPlugin(connection: DatabaseConnection) { NSApplication.shared.closeWindows(withId: "main") openWindow(id: "welcome") @@ -989,15 +996,7 @@ struct ConnectionFormView: View { do { try await dbManager.connectToSession(connection) } catch { - Self.logger.error( - "Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)") - NSApplication.shared.closeWindows(withId: "main") - openWindow(id: "welcome") - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: nil - ) + handleConnectError(error, connection: connection) } } } From de9276597626df89c926ef26a56dbf55d0bd2fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 31 Mar 2026 15:55:15 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20dedup?= =?UTF-8?q?licate=20window=20cleanup,=20prompt=20in=20test=20flow,=20clean?= =?UTF-8?q?=20xcstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/AppDelegate+ConnectionHandler.swift | 39 ++++----- TablePro/Core/Database/DatabaseDriver.swift | 5 +- .../Models/Connection/ConnectionSession.swift | 1 + TablePro/Resources/Localizable.xcstrings | 83 ------------------- .../Views/Connection/ConnectionFormView.swift | 39 +++++---- 5 files changed, 43 insertions(+), 124 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index c97224317..d3123e33a 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -430,38 +430,31 @@ extension AppDelegate { // MARK: - Connection Failure func handleConnectionFailure(_ error: Error) async { - // User cancelled password prompt — clean up windows silently, no error dialog - if error is CancellationError { - for window in NSApp.windows where isMainWindow(window) { - let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains { - window.subtitle == $0.connection.name - || window.subtitle == "\($0.connection.name) — Preview" - } - if !hasActiveSession { window.close() } - } - if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) { - openWelcomeWindow() - } - return - } + closeOrphanedMainWindows() + + // User cancelled password prompt — no error dialog needed + if error is CancellationError { return } + + try? await Task.sleep(for: .milliseconds(200)) + AlertHelper.showErrorSheet( + title: String(localized: "Connection Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + + /// Closes main windows that have no active database session, then opens the welcome window if none remain. + private func closeOrphanedMainWindows() { for window in NSApp.windows where isMainWindow(window) { let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains { window.subtitle == $0.connection.name || window.subtitle == "\($0.connection.name) — Preview" } - if !hasActiveSession { - window.close() - } + if !hasActiveSession { window.close() } } if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) { openWelcomeWindow() } - try? await Task.sleep(for: .milliseconds(200)) - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) } // MARK: - Transient Connection Builder diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index b6dd0e4df..73c4d9354 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -349,7 +349,10 @@ enum DatabaseDriverFactory { return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) } - private static func resolvePassword(for connection: DatabaseConnection, override: String? = nil) -> String { + private static func resolvePassword( + for connection: DatabaseConnection, + override: String? = nil + ) -> String { if let override { return override } if connection.usePgpass { let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index f9fd1463a..e2a7129b6 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -61,6 +61,7 @@ struct ConnectionSession: Identifiable { /// Clear cached data that can be re-fetched on reconnect. /// Called when the connection enters a disconnected or error state /// to release memory held by stale table metadata. + /// Note: `cachedPassword` is intentionally NOT cleared — auto-reconnect needs it after disconnect. mutating func clearCachedData() { tables = [] selectedTables = [] diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d7944df2c..5f8373995 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3386,9 +3386,6 @@ } } } - }, - "AI-Powered Assistant" : { - }, "AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss." : { "localizations" : { @@ -5156,9 +5153,6 @@ } } } - }, - "Browse, edit, and manage your data with ease" : { - }, "Browse..." : { "localizations" : { @@ -6813,9 +6807,6 @@ } } } - }, - "Comma-separated values. Compatible with Excel and most tools." : { - }, "Command Preview" : { "extractionState" : "stale", @@ -7056,9 +7047,6 @@ } } } - }, - "Connect to popular databases with full feature support" : { - }, "Connect to the internet to verify your license." : { "localizations" : { @@ -7917,9 +7905,6 @@ } } } - }, - "Copy error message" : { - }, "Copy Name" : { "localizations" : { @@ -11981,9 +11966,6 @@ } } } - }, - "Excel spreadsheet with formatting support." : { - }, "Execute" : { "localizations" : { @@ -12978,7 +12960,6 @@ } }, "Failed to decompress file: %@" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13292,7 +13273,6 @@ } }, "Failed to load preview using encoding: %@. Try selecting a different text encoding." : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13315,7 +13295,6 @@ } }, "Failed to load preview: %@" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -14343,9 +14322,6 @@ } } } - }, - "Format SQL" : { - }, "Format:" : { "localizations" : { @@ -14522,9 +14498,6 @@ } } } - }, - "Get intelligent SQL suggestions and query assistance" : { - }, "Get Started" : { "localizations" : { @@ -16444,9 +16417,6 @@ } } } - }, - "Interactive Data Grid" : { - }, "Interface" : { "localizations" : { @@ -18775,9 +18745,6 @@ } } } - }, - "MongoDB query language. Use to import into MongoDB." : { - }, "Move Down" : { "extractionState" : "stale", @@ -19032,9 +18999,6 @@ } } } - }, - "MySQL, PostgreSQL & SQLite" : { - }, "Name" : { "localizations" : { @@ -20748,9 +20712,6 @@ } } } - }, - "No rows returned" : { - }, "No saved connection named \"%@\"." : { "localizations" : { @@ -24311,7 +24272,6 @@ } }, "Query executing..." : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -25623,15 +25583,6 @@ } } } - }, - "Reset" : { - - }, - "Reset All Settings" : { - - }, - "Reset All Settings to Defaults" : { - }, "Reset to Defaults" : { "localizations" : { @@ -27225,9 +27176,6 @@ } } } - }, - "Secure Connections" : { - }, "SELECT * FROM users WHERE id = 1;" : { "extractionState" : "stale", @@ -28595,9 +28543,6 @@ } } } - }, - "Smart SQL Editor" : { - }, "Smooth" : { "localizations" : { @@ -28642,9 +28587,6 @@ } } } - }, - "Some columns in this preset don't exist in the current table" : { - }, "Something went wrong (error %lld). Try again in a moment." : { "localizations" : { @@ -28912,9 +28854,6 @@ } } } - }, - "SQL INSERT statements. Use to recreate data in another database." : { - }, "SQL Preview" : { "localizations" : { @@ -29304,9 +29243,6 @@ } } } - }, - "SSH tunneling and SSL/TLS encryption support" : { - }, "SSH User" : { "localizations" : { @@ -29806,9 +29742,6 @@ } } } - }, - "Structured data format. Ideal for APIs and web applications." : { - }, "Success" : { "localizations" : { @@ -30017,7 +29950,6 @@ } }, "Sync" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30038,9 +29970,6 @@ } } } - }, - "Sync (Pro)" : { - }, "Sync Categories" : { "localizations" : { @@ -30359,9 +30288,6 @@ } } } - }, - "Syntax highlighting, autocomplete, and multi-tab editing" : { - }, "System" : { "localizations" : { @@ -31880,9 +31806,6 @@ } } } - }, - "This will reset all settings across every section to their default values." : { - }, "Tier:" : { "localizations" : { @@ -34821,12 +34744,6 @@ } } } - }, - "Zoom In" : { - - }, - "Zoom Out" : { - } }, "version" : "1.0" diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 320340165..f80a0dfad 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -962,23 +962,18 @@ struct ConnectionFormView: View { } private func handleConnectError(_ error: Error, connection: DatabaseConnection) { - if error is CancellationError { - NSApplication.shared.closeWindows(withId: "main") - openWindow(id: "welcome") - } else if case PluginError.pluginNotInstalled = error { - Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") + if case PluginError.pluginNotInstalled = error { handleMissingPlugin(connection: connection) - } else { - Self.logger.error( - "Failed to connect: \(error.localizedDescription, privacy: .public)") - NSApplication.shared.closeWindows(withId: "main") - openWindow(id: "welcome") - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: nil - ) + return } + NSApplication.shared.closeWindows(withId: "main") + openWindow(id: "welcome") + guard !(error is CancellationError) else { return } + Self.logger.error("Failed to connect: \(error.localizedDescription, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Connection Failed"), + message: error.localizedDescription, window: nil + ) } private func handleMissingPlugin(connection: DatabaseConnection) { @@ -1100,11 +1095,21 @@ struct ConnectionFormView: View { } let sshPasswordForTest = sshProfileId == nil ? sshPassword : nil - let testPasswordOverride = promptForPassword && !password.isEmpty ? password : nil + let isApiOnly = PluginManager.shared.connectionMode(for: type) == .apiOnly + let testPwOverride: String? = promptForPassword + ? (password.isEmpty + ? PasswordPromptHelper.prompt(connectionName: name.isEmpty ? host : name, isAPIToken: isApiOnly) + : password) + : nil + guard !promptForPassword || testPwOverride != nil else { + cleanupTestSecrets(for: testConn.id) + isTesting = false + return + } let success = try await DatabaseManager.shared.testConnection( testConn, sshPassword: sshPasswordForTest, - passwordOverride: testPasswordOverride + passwordOverride: testPwOverride ) cleanupTestSecrets(for: testConn.id) await MainActor.run {