Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Safe mode levels: per-connection setting with 6 levels (Silent, Alert, Alert Full, Safe Mode, Safe Mode Full, Read-Only) replacing the boolean read-only toggle, with confirmation dialogs and Touch ID/password authentication for stricter levels
- Preview tabs: single-click opens a temporary preview tab, double-click or editing promotes it to a permanent tab
- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture
- `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins
Expand Down
10 changes: 5 additions & 5 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ struct ContentView: View {
)
}
AppState.shared.isConnected = true
AppState.shared.isReadOnly = session.connection.isReadOnly
AppState.shared.safeModeLevel = session.connection.safeModeLevel
AppState.shared.isMongoDB = session.connection.type == .mongodb
AppState.shared.isRedis = session.connection.type == .redis
}
Expand All @@ -137,7 +137,7 @@ struct ContentView: View {
currentSession = nil
columnVisibility = .detailOnly
AppState.shared.isConnected = false
AppState.shared.isReadOnly = false
AppState.shared.safeModeLevel = .silent
AppState.shared.isMongoDB = false
AppState.shared.isRedis = false

Expand Down Expand Up @@ -168,7 +168,7 @@ struct ContentView: View {
)
}
AppState.shared.isConnected = true
AppState.shared.isReadOnly = newSession.connection.isReadOnly
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
AppState.shared.isMongoDB = newSession.connection.type == .mongodb
AppState.shared.isRedis = newSession.connection.type == .redis
}
Expand Down Expand Up @@ -196,12 +196,12 @@ struct ContentView: View {

if let session = DatabaseManager.shared.activeSessions[connectionId] {
AppState.shared.isConnected = true
AppState.shared.isReadOnly = session.connection.isReadOnly
AppState.shared.safeModeLevel = session.connection.safeModeLevel
AppState.shared.isMongoDB = session.connection.type == .mongodb
AppState.shared.isRedis = session.connection.type == .redis
} else {
AppState.shared.isConnected = false
AppState.shared.isReadOnly = false
AppState.shared.safeModeLevel = .silent
AppState.shared.isMongoDB = false
AppState.shared.isRedis = false
}
Expand Down
118 changes: 118 additions & 0 deletions TablePro/Core/Services/Infrastructure/SafeModeGuard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// SafeModeGuard.swift
// TablePro
//

import AppKit
import LocalAuthentication
import os

@MainActor
internal final class SafeModeGuard {
private static let logger = Logger(subsystem: "com.TablePro", category: "SafeModeGuard")

internal enum Permission {
case allowed
case blocked(String)
}

internal static func checkPermission(
level: SafeModeLevel,
isWriteOperation: Bool,
sql: String,
operationDescription: String,
window: NSWindow?,
databaseType: DatabaseType? = nil
) async -> Permission {
let effectiveIsWrite: Bool
if let dbType = databaseType, dbType == .mongodb || dbType == .redis {
effectiveIsWrite = true
} else {
effectiveIsWrite = isWriteOperation
}

switch level {
case .silent:
return .allowed

case .readOnly:
if effectiveIsWrite {
return .blocked(String(localized: "Cannot execute write queries: connection is read-only"))
}
return .allowed

case .alert:
if effectiveIsWrite {
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
return .blocked(String(localized: "Operation cancelled by user"))
}
}
return .allowed

case .alertFull:
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
return .blocked(String(localized: "Operation cancelled by user"))
}
return .allowed

case .safeMode:
if effectiveIsWrite {
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
return .blocked(String(localized: "Operation cancelled by user"))
}
guard await authenticateUser() else {
return .blocked(String(localized: "Authentication required to execute write operations"))
}
}
return .allowed

case .safeModeFull:
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
return .blocked(String(localized: "Operation cancelled by user"))
}
guard await authenticateUser() else {
return .blocked(String(localized: "Authentication required to execute operations"))
}
return .allowed
}
}

private static func showConfirmationAlert(
sql: String,
operationDescription: String,
window: NSWindow?
) async -> Bool {
let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines)
let preview: String
if (trimmed as NSString).length > 200 {
preview = String(trimmed.prefix(200)) + "..."
} else {
preview = trimmed
}

return await AlertHelper.confirmDestructive(
title: operationDescription,
message: String(localized: "Are you sure you want to execute this query?\n\n\(preview)"),
confirmButton: String(localized: "Execute"),
cancelButton: String(localized: "Cancel"),
window: window
)
}

private static func authenticateUser() async -> Bool {
await Task.detached {
let context = LAContext()
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: String(localized: "Authenticate to execute database operations")
)
} catch {
await MainActor.run {
logger.warning("Biometric authentication failed: \(error.localizedDescription)")
}
return false
}
}.value
}
}
62 changes: 55 additions & 7 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ final class ConnectionStorage {
color: connection.color,
tagId: connection.tagId,
groupId: connection.groupId,
isReadOnly: connection.isReadOnly,
safeModeLevel: connection.safeModeLevel,
aiPolicy: connection.aiPolicy,
mongoReadPreference: connection.mongoReadPreference,
mongoWriteConcern: connection.mongoWriteConcern,
Expand Down Expand Up @@ -362,8 +362,8 @@ private struct StoredConnection: Codable {
let tagId: String?
let groupId: String?

// Read-only mode
let isReadOnly: Bool
// Safe mode level
let safeModeLevel: String

// AI policy
let aiPolicy: String?
Expand Down Expand Up @@ -407,8 +407,8 @@ private struct StoredConnection: Codable {
self.tagId = connection.tagId?.uuidString
self.groupId = connection.groupId?.uuidString

// Read-only mode
self.isReadOnly = connection.isReadOnly
// Safe mode level
self.safeModeLevel = connection.safeModeLevel.rawValue

// AI policy
self.aiPolicy = connection.aiPolicy?.rawValue
Expand All @@ -423,6 +423,48 @@ private struct StoredConnection: Codable {
self.startupCommands = connection.startupCommands
}

private enum CodingKeys: String, CodingKey {
case id, name, host, port, database, username, type
case sshEnabled, sshHost, sshPort, sshUsername, sshAuthMethod, sshPrivateKeyPath
case sshUseSSHConfig, sshAgentSocketPath
case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath
case color, tagId, groupId
case safeModeLevel
case isReadOnly // Legacy key for migration reading only
case aiPolicy, mssqlSchema, oracleServiceName, startupCommands
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(host, forKey: .host)
try container.encode(port, forKey: .port)
try container.encode(database, forKey: .database)
try container.encode(username, forKey: .username)
try container.encode(type, forKey: .type)
try container.encode(sshEnabled, forKey: .sshEnabled)
try container.encode(sshHost, forKey: .sshHost)
try container.encode(sshPort, forKey: .sshPort)
try container.encode(sshUsername, forKey: .sshUsername)
try container.encode(sshAuthMethod, forKey: .sshAuthMethod)
try container.encode(sshPrivateKeyPath, forKey: .sshPrivateKeyPath)
try container.encode(sshUseSSHConfig, forKey: .sshUseSSHConfig)
try container.encode(sshAgentSocketPath, forKey: .sshAgentSocketPath)
try container.encode(sslMode, forKey: .sslMode)
try container.encode(sslCaCertificatePath, forKey: .sslCaCertificatePath)
try container.encode(sslClientCertificatePath, forKey: .sslClientCertificatePath)
try container.encode(sslClientKeyPath, forKey: .sslClientKeyPath)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(tagId, forKey: .tagId)
try container.encodeIfPresent(groupId, forKey: .groupId)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encodeIfPresent(mssqlSchema, forKey: .mssqlSchema)
try container.encodeIfPresent(oracleServiceName, forKey: .oracleServiceName)
try container.encodeIfPresent(startupCommands, forKey: .startupCommands)
}

// Custom decoder to handle migration from old format
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
Expand Down Expand Up @@ -456,7 +498,13 @@ private struct StoredConnection: Codable {
color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue
tagId = try container.decodeIfPresent(String.self, forKey: .tagId)
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
// Migration: read new safeModeLevel first, fall back to old isReadOnly boolean
if let levelString = try container.decodeIfPresent(String.self, forKey: .safeModeLevel) {
safeModeLevel = levelString
} else {
let wasReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
safeModeLevel = wasReadOnly ? SafeModeLevel.readOnly.rawValue : SafeModeLevel.silent.rawValue
}
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema)
oracleServiceName = try container.decodeIfPresent(String.self, forKey: .oracleServiceName)
Expand Down Expand Up @@ -500,7 +548,7 @@ private struct StoredConnection: Codable {
color: parsedColor,
tagId: parsedTagId,
groupId: parsedGroupId,
isReadOnly: isReadOnly,
safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent,
aiPolicy: parsedAIPolicy,
mssqlSchema: mssqlSchema,
oracleServiceName: oracleServiceName,
Expand Down
14 changes: 8 additions & 6 deletions TablePro/Models/Connection/ConnectionToolbarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,10 @@ final class ConnectionToolbarState {

// MARK: - Future Expansion

/// Whether the connection is read-only
var isReadOnly: Bool = false
/// Safe mode level for this connection
var safeModeLevel: SafeModeLevel = .silent

var isReadOnly: Bool { safeModeLevel == .readOnly }

/// Whether the current tab is a table tab (enables filter/sort actions)
var isTableTab: Bool = false
Expand Down Expand Up @@ -210,8 +212,8 @@ final class ConnectionToolbarState {
parts.append(String(localized: "Replication lag: \(lag)s"))
}

if isReadOnly {
parts.append(String(localized: "Read-only"))
if safeModeLevel != .silent {
parts.append(safeModeLevel.displayName)
}

return parts.joined(separator: " • ")
Expand Down Expand Up @@ -246,7 +248,7 @@ final class ConnectionToolbarState {
databaseType = connection.type
displayColor = connection.displayColor
tagId = connection.tagId
isReadOnly = connection.isReadOnly
safeModeLevel = connection.safeModeLevel
}

/// Update connection state from ConnectionStatus
Expand Down Expand Up @@ -276,7 +278,7 @@ final class ConnectionToolbarState {
lastQueryDuration = nil
clickHouseProgress = nil
lastClickHouseProgress = nil
isReadOnly = false
safeModeLevel = .silent
isTableTab = false
latencyMs = nil
replicationLagSeconds = nil
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var color: ConnectionColor
var tagId: UUID?
var groupId: UUID?
var isReadOnly: Bool
var safeModeLevel: SafeModeLevel
var aiPolicy: AIConnectionPolicy?
var mongoReadPreference: String?
var mongoWriteConcern: String?
Expand All @@ -420,7 +420,7 @@ struct DatabaseConnection: Identifiable, Hashable {
color: ConnectionColor = .none,
tagId: UUID? = nil,
groupId: UUID? = nil,
isReadOnly: Bool = false,
safeModeLevel: SafeModeLevel = .silent,
aiPolicy: AIConnectionPolicy? = nil,
mongoReadPreference: String? = nil,
mongoWriteConcern: String? = nil,
Expand All @@ -441,7 +441,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.color = color
self.tagId = tagId
self.groupId = groupId
self.isReadOnly = isReadOnly
self.safeModeLevel = safeModeLevel
self.aiPolicy = aiPolicy
self.mongoReadPreference = mongoReadPreference
self.mongoWriteConcern = mongoWriteConcern
Expand Down
Loading