Skip to content

Commit 8adf763

Browse files
authored
feat: add option to prompt for database password on every connection (#537)
* feat: add option to prompt for database password on every connection (#534) * fix: handle password prompt cancellation gracefully, allow empty passwords * fix: address review — deduplicate window cleanup, prompt in test flow, clean xcstrings
1 parent 22d9b1a commit 8adf763

13 files changed

Lines changed: 239 additions & 54 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Option to prompt for database password on every connection instead of saving to Keychain
1213
- Autocompletion for filter fields: column names and SQL keywords suggested as you type (Raw SQL and Value fields)
1314
- Multi-line support for Raw SQL filter field (Option+Enter for newline)
1415
- Visual Create Table UI with multi-database support (sidebar → "Create New Table...")

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -430,24 +430,31 @@ extension AppDelegate {
430430
// MARK: - Connection Failure
431431

432432
func handleConnectionFailure(_ error: Error) async {
433+
closeOrphanedMainWindows()
434+
435+
// User cancelled password prompt — no error dialog needed
436+
if error is CancellationError { return }
437+
438+
try? await Task.sleep(for: .milliseconds(200))
439+
AlertHelper.showErrorSheet(
440+
title: String(localized: "Connection Failed"),
441+
message: error.localizedDescription,
442+
window: NSApp.keyWindow
443+
)
444+
}
445+
446+
/// Closes main windows that have no active database session, then opens the welcome window if none remain.
447+
private func closeOrphanedMainWindows() {
433448
for window in NSApp.windows where isMainWindow(window) {
434449
let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains {
435450
window.subtitle == $0.connection.name
436451
|| window.subtitle == "\($0.connection.name) — Preview"
437452
}
438-
if !hasActiveSession {
439-
window.close()
440-
}
453+
if !hasActiveSession { window.close() }
441454
}
442455
if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) {
443456
openWelcomeWindow()
444457
}
445-
try? await Task.sleep(for: .milliseconds(200))
446-
AlertHelper.showErrorSheet(
447-
title: String(localized: "Connection Failed"),
448-
message: error.localizedDescription,
449-
window: NSApp.keyWindow
450-
)
451458
}
452459

453460
// MARK: - Transient Connection Builder

TablePro/AppDelegate+WindowConfig.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,12 @@ extension AppDelegate {
339339
for window in NSApp.windows where self.isWelcomeWindow(window) {
340340
window.close()
341341
}
342+
} catch is CancellationError {
343+
// User cancelled password prompt at startup — return to welcome
344+
for window in NSApp.windows where self.isMainWindow(window) {
345+
window.close()
346+
}
347+
self.openWelcomeWindow()
342348
} catch {
343349
windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)")
344350

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,10 @@ extension DatabaseDriver {
317317
enum DatabaseDriverFactory {
318318
private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseDriverFactory")
319319

320-
static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver {
320+
static func createDriver(
321+
for connection: DatabaseConnection,
322+
passwordOverride: String? = nil
323+
) throws -> DatabaseDriver {
321324
let pluginId = connection.type.pluginTypeId
322325
// If the plugin isn't registered yet and background loading hasn't finished,
323326
// fall back to synchronous loading for this critical code path.
@@ -338,15 +341,19 @@ enum DatabaseDriverFactory {
338341
host: connection.host,
339342
port: connection.port,
340343
username: connection.username,
341-
password: resolvePassword(for: connection),
344+
password: resolvePassword(for: connection, override: passwordOverride),
342345
database: connection.database,
343346
additionalFields: buildAdditionalFields(for: connection, plugin: plugin)
344347
)
345348
let pluginDriver = plugin.createDriver(config: config)
346349
return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver)
347350
}
348351

349-
private static func resolvePassword(for connection: DatabaseConnection) -> String {
352+
private static func resolvePassword(
353+
for connection: DatabaseConnection,
354+
override: String? = nil
355+
) -> String {
356+
if let override { return override }
350357
if connection.usePgpass {
351358
let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host
352359
let pgpassPort = connection.additionalFields["pgpassOriginalPort"]

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,32 @@ final class DatabaseManager {
133133
}
134134
}
135135

136+
// Resolve password override for prompt-for-password connections
137+
var passwordOverride: String?
138+
if connection.promptForPassword {
139+
if let cached = activeSessions[connection.id]?.cachedPassword {
140+
passwordOverride = cached
141+
} else {
142+
let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly
143+
guard let prompted = PasswordPromptHelper.prompt(
144+
connectionName: connection.name,
145+
isAPIToken: isApiOnly
146+
) else {
147+
removeSessionEntry(for: connection.id)
148+
currentSessionId = nil
149+
throw CancellationError()
150+
}
151+
passwordOverride = prompted
152+
}
153+
}
154+
136155
// Create appropriate driver with effective connection
137156
let driver: DatabaseDriver
138157
do {
139-
driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection)
158+
driver = try DatabaseDriverFactory.createDriver(
159+
for: effectiveConnection,
160+
passwordOverride: passwordOverride
161+
)
140162
} catch {
141163
// Close tunnel if SSH was established
142164
if connection.sshConfig.enabled {
@@ -217,7 +239,9 @@ final class DatabaseManager {
217239
session.driver = driver
218240
session.status = driver.status
219241
session.effectiveConnection = effectiveConnection
220-
242+
if let passwordOverride {
243+
session.cachedPassword = passwordOverride
244+
}
221245
setSession(session, for: connection.id)
222246
}
223247

@@ -418,9 +442,11 @@ final class DatabaseManager {
418442
}
419443

420444
/// Test a connection without keeping it open
421-
func testConnection(_ connection: DatabaseConnection, sshPassword: String? = nil) async throws
422-
-> Bool
423-
{
445+
func testConnection(
446+
_ connection: DatabaseConnection,
447+
sshPassword: String? = nil,
448+
passwordOverride: String? = nil
449+
) async throws -> Bool {
424450
// Build effective connection (creates SSH tunnel if needed)
425451
let testConnection = try await buildEffectiveConnection(
426452
for: connection,
@@ -429,7 +455,10 @@ final class DatabaseManager {
429455

430456
let result: Bool
431457
do {
432-
let driver = try DatabaseDriverFactory.createDriver(for: testConnection)
458+
let driver = try DatabaseDriverFactory.createDriver(
459+
for: testConnection,
460+
passwordOverride: passwordOverride
461+
)
433462
result = try await driver.testConnection()
434463
} catch {
435464
if connection.sshConfig.enabled {
@@ -643,7 +672,10 @@ final class DatabaseManager {
643672

644673
// Use effective connection (tunneled) if available, otherwise original
645674
let connectionForDriver = session.effectiveConnection ?? session.connection
646-
let driver = try DatabaseDriverFactory.createDriver(for: connectionForDriver)
675+
let driver = try DatabaseDriverFactory.createDriver(
676+
for: connectionForDriver,
677+
passwordOverride: session.cachedPassword
678+
)
647679
try await driver.connect()
648680

649681
// Apply timeout
@@ -712,8 +744,25 @@ final class DatabaseManager {
712744
// Recreate SSH tunnel if needed and build effective connection
713745
let effectiveConnection = try await buildEffectiveConnection(for: session.connection)
714746

747+
// Resolve password for prompt-for-password connections
748+
var passwordOverride = activeSessions[sessionId]?.cachedPassword
749+
if session.connection.promptForPassword && passwordOverride == nil {
750+
let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly
751+
guard let prompted = PasswordPromptHelper.prompt(
752+
connectionName: session.connection.name,
753+
isAPIToken: isApiOnly
754+
) else {
755+
updateSession(sessionId) { $0.status = .disconnected }
756+
return
757+
}
758+
passwordOverride = prompted
759+
}
760+
715761
// Create new driver and connect
716-
let driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection)
762+
let driver = try DatabaseDriverFactory.createDriver(
763+
for: effectiveConnection,
764+
passwordOverride: passwordOverride
765+
)
717766
try await driver.connect()
718767

719768
// Apply timeout
@@ -750,6 +799,9 @@ final class DatabaseManager {
750799
session.driver = driver
751800
session.status = .connected
752801
session.effectiveConnection = effectiveConnection
802+
if let passwordOverride {
803+
session.cachedPassword = passwordOverride
804+
}
753805
}
754806

755807
// Restart health monitoring if the plugin supports it

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ final class ConnectionStorage {
163163
saveConnections(connections)
164164
SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString)
165165

166-
// Copy all passwords from source to duplicate
167-
if let password = loadPassword(for: connection.id) {
166+
// Copy all passwords from source to duplicate (skip DB password in prompt mode)
167+
if !connection.promptForPassword, let password = loadPassword(for: connection.id) {
168168
savePassword(password, for: newId)
169169
}
170170
if let sshPassword = loadSSHPassword(for: connection.id) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// PasswordPromptHelper.swift
3+
// TablePro
4+
//
5+
// Prompts the user for a database password via a native modal alert.
6+
//
7+
8+
import AppKit
9+
10+
enum PasswordPromptHelper {
11+
/// Presents a modal alert with a secure text field to collect a password or API token.
12+
/// Returns the entered value (may be empty for passwordless databases), or `nil` if the user cancels.
13+
@MainActor
14+
static func prompt(connectionName: String, isAPIToken: Bool = false) -> String? {
15+
let alert = NSAlert()
16+
alert.messageText = isAPIToken
17+
? String(localized: "API Token Required")
18+
: String(localized: "Password Required")
19+
alert.informativeText = String(
20+
format: String(localized: "Enter the %@ for \"%@\""),
21+
isAPIToken ? String(localized: "API token") : String(localized: "password"),
22+
connectionName
23+
)
24+
alert.addButton(withTitle: String(localized: "Connect"))
25+
alert.addButton(withTitle: String(localized: "Cancel"))
26+
27+
let input = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
28+
input.placeholderString = isAPIToken
29+
? String(localized: "API Token") : String(localized: "Password")
30+
alert.accessoryView = input
31+
alert.window.initialFirstResponder = input
32+
33+
guard alert.runModal() == .alertFirstButtonReturn else { return nil }
34+
return input.stringValue
35+
}
36+
}

TablePro/Models/Connection/ConnectionSession.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ struct ConnectionSession: Identifiable {
2626
var currentSchema: String?
2727
var currentDatabase: String?
2828

29+
/// In-memory password for prompt-for-password connections. Never persisted to disk.
30+
var cachedPassword: String?
31+
2932
var activeDatabase: String {
3033
currentDatabase ?? connection.database
3134
}
@@ -58,6 +61,7 @@ struct ConnectionSession: Identifiable {
5861
/// Clear cached data that can be re-fetched on reconnect.
5962
/// Called when the connection enters a disconnected or error state
6063
/// to release memory held by stale table metadata.
64+
/// Note: `cachedPassword` is intentionally NOT cleared — auto-reconnect needs it after disconnect.
6165
mutating func clearCachedData() {
6266
tables = []
6367
selectedTables = []

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,11 @@ struct DatabaseConnection: Identifiable, Hashable {
429429
set { additionalFields["usePgpass"] = newValue ? "true" : "" }
430430
}
431431

432+
var promptForPassword: Bool {
433+
get { additionalFields["promptForPassword"] == "true" }
434+
set { additionalFields["promptForPassword"] = newValue ? "true" : "" }
435+
}
436+
432437
var preConnectScript: String? {
433438
get { additionalFields["preConnectScript"]?.nilIfEmpty }
434439
set { additionalFields["preConnectScript"] = newValue ?? "" }

TablePro/ViewModels/WelcomeViewModel.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ final class WelcomeViewModel {
215215
Task {
216216
do {
217217
try await dbManager.connectToSession(connection)
218+
} catch is CancellationError {
219+
// User cancelled password prompt — return to welcome
220+
NSApplication.shared.closeWindows(withId: "main")
221+
self.openWindow?(id: "welcome")
218222
} catch {
219223
if case PluginError.pluginNotInstalled = error {
220224
Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install")
@@ -237,6 +241,9 @@ final class WelcomeViewModel {
237241
Task {
238242
do {
239243
try await dbManager.connectToSession(connection)
244+
} catch is CancellationError {
245+
NSApplication.shared.closeWindows(withId: "main")
246+
self.openWindow?(id: "welcome")
240247
} catch {
241248
Self.logger.error(
242249
"Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)")

0 commit comments

Comments
 (0)