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/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 253fcba5d..d3123e33a 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -430,24 +430,31 @@ extension AppDelegate { // MARK: - Connection Failure func handleConnectionFailure(_ error: Error) async { + 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/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/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index dddcb78d5..73c4d9354 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,11 @@ 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..143671306 --- /dev/null +++ b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift @@ -0,0 +1,36 @@ +// +// 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 (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() + 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 } + return input.stringValue + } +} diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index 42d31787b..e2a7129b6 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 } @@ -58,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/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/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 4c3e708a5..f80a0dfad 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) @@ -948,24 +956,26 @@ 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 case PluginError.pluginNotInstalled = error { + handleMissingPlugin(connection: connection) + 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) { NSApplication.shared.closeWindows(withId: "main") openWindow(id: "welcome") @@ -981,15 +991,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) } } } @@ -1064,8 +1066,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 +1095,22 @@ struct ConnectionFormView: View { } let sshPasswordForTest = sshProfileId == nil ? sshPassword : 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) + testConn, + sshPassword: sshPasswordForTest, + passwordOverride: testPwOverride + ) 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 + ) + } + } +}