diff --git a/CHANGELOG.md b/CHANGELOG.md index 020df663b..9e566c07a 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 +- 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 diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index d7f230de9..6e9d26dbd 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -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 } @@ -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 @@ -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 } @@ -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 } diff --git a/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift b/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift new file mode 100644 index 000000000..b3ccdfa88 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift @@ -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 + } +} diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index e7bd9377f..cde4cb3fc 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -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, @@ -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? @@ -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 @@ -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) @@ -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) @@ -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, diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 817659b62..45d2a7d9c 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -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 @@ -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: " • ") @@ -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 @@ -276,7 +278,7 @@ final class ConnectionToolbarState { lastQueryDuration = nil clickHouseProgress = nil lastClickHouseProgress = nil - isReadOnly = false + safeModeLevel = .silent isTableTab = false latencyMs = nil replicationLagSeconds = nil diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 6e58343cd..de2481216 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -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? @@ -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, @@ -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 diff --git a/TablePro/Models/Connection/SafeModeLevel.swift b/TablePro/Models/Connection/SafeModeLevel.swift new file mode 100644 index 000000000..15e45dc93 --- /dev/null +++ b/TablePro/Models/Connection/SafeModeLevel.swift @@ -0,0 +1,74 @@ +// +// SafeModeLevel.swift +// TablePro +// + +import SwiftUI + +internal enum SafeModeLevel: String, Codable, CaseIterable, Identifiable { + case silent = "silent" + case alert = "alert" + case alertFull = "alertFull" + case safeMode = "safeMode" + case safeModeFull = "safeModeFull" + case readOnly = "readOnly" +} + +internal extension SafeModeLevel { + var id: String { rawValue } + + var displayName: String { + switch self { + case .silent: return String(localized: "Silent") + case .alert: return String(localized: "Alert") + case .alertFull: return String(localized: "Alert (Full)") + case .safeMode: return String(localized: "Safe Mode") + case .safeModeFull: return String(localized: "Safe Mode (Full)") + case .readOnly: return String(localized: "Read-Only") + } + } + + var blocksAllWrites: Bool { + self == .readOnly + } + + var requiresConfirmation: Bool { + switch self { + case .alert, .alertFull, .safeMode, .safeModeFull: return true + case .silent, .readOnly: return false + } + } + + var requiresAuthentication: Bool { + switch self { + case .safeMode, .safeModeFull: return true + case .silent, .alert, .alertFull, .readOnly: return false + } + } + + var appliesToAllQueries: Bool { + switch self { + case .alertFull, .safeModeFull: return true + case .silent, .alert, .safeMode, .readOnly: return false + } + } + + var iconName: String { + switch self { + case .silent: return "lock.open" + case .alert: return "exclamationmark.triangle" + case .alertFull: return "exclamationmark.triangle.fill" + case .safeMode: return "lock.shield" + case .safeModeFull: return "lock.shield.fill" + case .readOnly: return "lock.fill" + } + } + + var badgeColor: Color { + switch self { + case .silent: return .secondary + case .alert, .alertFull: return .orange + case .safeMode, .safeModeFull, .readOnly: return .red + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 61b333c23..4f315c712 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1802,6 +1802,12 @@ } } } + }, + "Alert" : { + + }, + "Alert (Full)" : { + }, "All %lld rows selected" : { "localizations" : { @@ -2155,6 +2161,9 @@ } } } + }, + "Are you sure you want to execute this query?\n\n%@" : { + }, "Ask about your database..." : { "localizations" : { @@ -2235,6 +2244,9 @@ } } } + }, + "Authenticate to execute database operations" : { + }, "Authentication" : { "localizations" : { @@ -2283,6 +2295,12 @@ } } } + }, + "Authentication required to execute operations" : { + + }, + "Authentication required to execute write operations" : { + }, "AUTO" : { "extractionState" : "stale", @@ -2589,6 +2607,9 @@ } } } + }, + "Cannot execute write queries: connection is read-only" : { + }, "Cannot format empty SQL" : { "localizations" : { @@ -6368,6 +6389,9 @@ } } } + }, + "Execute Query" : { + }, "Executed %lld statements" : { "localizations" : { @@ -11266,6 +11290,9 @@ } } } + }, + "Operation cancelled by user" : { + }, "Optimize Query" : { "localizations" : { @@ -12001,6 +12028,7 @@ } }, "Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -12549,6 +12577,7 @@ } }, "Read-only" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -12581,6 +12610,7 @@ } }, "Read-only connection" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -13347,6 +13377,15 @@ } } } + }, + "Safe Mode" : { + + }, + "Safe Mode (Full)" : { + + }, + "Safe Mode: %@" : { + }, "Same options will be applied to all selected tables." : { "localizations" : { @@ -13509,6 +13548,9 @@ } } } + }, + "Save Sidebar Changes" : { + }, "Save Table Template" : { "extractionState" : "stale", @@ -14369,6 +14411,9 @@ } } } + }, + "Silent" : { + }, "Single-clicking a table opens a temporary tab that gets replaced on next click." : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index d4200378c..83ac138d5 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -17,7 +17,8 @@ import SwiftUI final class AppState { static let shared = AppState() var isConnected: Bool = false - var isReadOnly: Bool = false // True when current connection is read-only + var safeModeLevel: SafeModeLevel = .silent + var isReadOnly: Bool { safeModeLevel.blocksAllWrites } var isMongoDB: Bool = false var isRedis: Bool = false var isCurrentTabEditable: Bool = false // True when current tab is an editable table diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 131eb4c85..f46660e1d 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -65,8 +65,8 @@ struct ConnectionFormView: View { @State private var selectedTagId: UUID? @State private var selectedGroupId: UUID? - // Read-only mode - @State private var isReadOnly: Bool = false + // Safe mode level + @State private var safeModeLevel: SafeModeLevel = .silent // AI policy @State private var aiPolicy: AIConnectionPolicy? @@ -276,8 +276,11 @@ struct ConnectionFormView: View { LabeledContent(String(localized: "Group")) { ConnectionGroupPicker(selectedGroupId: $selectedGroupId) } - Toggle(String(localized: "Read-Only"), isOn: $isReadOnly) - .help("Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)") + Picker(String(localized: "Safe Mode"), selection: $safeModeLevel) { + ForEach(SafeModeLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } } } .formStyle(.grouped) @@ -806,7 +809,7 @@ struct ConnectionFormView: View { connectionColor = existing.color selectedTagId = existing.tagId selectedGroupId = existing.groupId - isReadOnly = existing.isReadOnly + safeModeLevel = existing.safeModeLevel aiPolicy = existing.aiPolicy // Load MongoDB settings @@ -879,7 +882,7 @@ struct ConnectionFormView: View { color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, - isReadOnly: isReadOnly, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index a2378955f..b99c8647b 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -292,7 +292,7 @@ struct MainEditorContentView: View { changeManager: currentChangeManager, resultVersion: tab.resultVersion, metadataVersion: tab.metadataVersion, - isEditable: tab.isEditable && !tab.isView && !connection.isReadOnly, + isEditable: tab.isEditable && !tab.isView && !connection.safeModeLevel.blocksAllWrites, onRefresh: onRefresh, onCellEdit: onCellEdit, onUndo: { [binding = _selectedRowIndices, coordinator] in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index c61533314..4c2d27b82 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -5,13 +5,29 @@ // Sidebar transaction execution and discard handling. // +import AppKit import Foundation extension MainContentCoordinator { // MARK: - Table Creation /// Execute sidebar changes immediately (single transaction) + /// Respects safe mode levels that require confirmation for write operations. func executeSidebarChanges(statements: [ParameterizedStatement]) async throws { + let sqlPreview = statements.map(\.sql).joined(separator: "\n") + let window = await MainActor.run { NSApp.keyWindow } + let permission = await SafeModeGuard.checkPermission( + level: connection.safeModeLevel, + isWriteOperation: true, + sql: sqlPreview, + operationDescription: String(localized: "Save Sidebar Changes"), + window: window, + databaseType: connection.type + ) + if case .blocked = permission { + return + } + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { throw DatabaseError.notConnected } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index e4d8dcd8d..4664fdf92 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -11,7 +11,7 @@ extension MainContentCoordinator { // MARK: - Row Operations func addNewRow(selectedRowIndices: inout Set, editingCell: inout CellPosition?) { - guard !connection.isReadOnly, + guard !connection.safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } @@ -31,7 +31,7 @@ extension MainContentCoordinator { } func deleteSelectedRows(indices: Set, selectedRowIndices: inout Set) { - guard !connection.isReadOnly, + guard !connection.safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count, tabManager.tabs[tabIndex].isEditable, @@ -53,7 +53,7 @@ extension MainContentCoordinator { } func duplicateSelectedRow(index: Int, selectedRowIndices: inout Set, editingCell: inout CellPosition?) { - guard !connection.isReadOnly, + guard !connection.safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } @@ -138,7 +138,7 @@ extension MainContentCoordinator { } func pasteRows(selectedRowIndices: inout Set, editingCell: inout CellPosition?) { - guard !connection.isReadOnly, + guard !connection.safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } var tab = tabManager.tabs[index] diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 66a4beeed..ed7675ba0 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -34,7 +34,7 @@ extension MainContentView { /// Determine if sidebar should be in editable mode var isSidebarEditable: Bool { - guard !coordinator.connection.isReadOnly, + guard !coordinator.connection.safeModeLevel.blocksAllWrites, let tab = coordinator.tabManager.selectedTab, tab.tabType == .table || tab.tableName != nil, !selectedRowIndices.isEmpty else { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index d816a9c70..2477cc275 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -380,7 +380,7 @@ final class MainContentCommandActions { } func createView() { - guard !connection.isReadOnly else { return } + guard !connection.safeModeLevel.blocksAllWrites else { return } let template: String switch connection.type { @@ -462,7 +462,7 @@ final class MainContentCommandActions { } func importTables() { - guard !connection.isReadOnly else { return } + guard !connection.safeModeLevel.blocksAllWrites else { return } guard connection.type != .mongodb && connection.type != .redis else { let typeName = connection.type == .mongodb ? "MongoDB" : "Redis" AlertHelper.showErrorSheet( diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index c7485e789..c19f016f7 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -88,6 +88,9 @@ final class MainContentCoordinator { /// Guards against re-entrant confirm dialogs (e.g. nested run loop during runModal) @ObservationIgnored internal var isShowingConfirmAlert = false + /// Guards against duplicate safe mode confirmation prompts + @ObservationIgnored private var isShowingSafeModePrompt = false + /// Continuation for callers that need to await the result of a fire-and-forget save /// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`. @ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation? @@ -438,8 +441,10 @@ final class MainContentCoordinator { let statements = SQLStatementScanner.allStatements(in: sql) guard !statements.isEmpty else { return } - // Block write queries in read-only mode - if connection.isReadOnly { + // Safe mode enforcement for query execution + let level = connection.safeModeLevel + + if level == .readOnly { let writeStatements = statements.filter { isWriteQuery($0) } if !writeStatements.isEmpty { tabManager.tabs[index].errorMessage = @@ -448,31 +453,64 @@ final class MainContentCoordinator { } } - if statements.count == 1 { - // Single statement — existing path (unchanged) - Task { @MainActor in - let window = NSApp.keyWindow - guard await confirmDangerousQueryIfNeeded(statements[0], window: window) else { - return + if level == .silent { + if statements.count == 1 { + Task { @MainActor in + let window = NSApp.keyWindow + guard await confirmDangerousQueryIfNeeded(statements[0], window: window) else { return } + executeQueryInternal(statements[0]) + } + } else { + Task { @MainActor in + let window = NSApp.keyWindow + let dangerousStatements = statements.filter { isDangerousQuery($0) } + if !dangerousStatements.isEmpty { + guard await confirmDangerousQueries(dangerousStatements, window: window) else { return } + } + executeMultipleStatements(statements) } - executeQueryInternal(statements[0]) } - } else { - // Multiple statements — batch-check dangerous queries, then execute sequentially + } else if level.requiresConfirmation { + guard !isShowingSafeModePrompt else { return } + isShowingSafeModePrompt = true Task { @MainActor in + defer { isShowingSafeModePrompt = false } let window = NSApp.keyWindow - let dangerousStatements = statements.filter { isDangerousQuery($0) } - if !dangerousStatements.isEmpty { - guard await confirmDangerousQueries(dangerousStatements, window: window) else { return } + let combinedSQL = statements.joined(separator: "\n") + let hasWrite = statements.contains { isWriteQuery($0) } + let permission = await SafeModeGuard.checkPermission( + level: level, + isWriteOperation: hasWrite, + sql: combinedSQL, + operationDescription: String(localized: "Execute Query"), + window: window, + databaseType: connection.type + ) + switch permission { + case .allowed: + if statements.count == 1 { + executeQueryInternal(statements[0]) + } else { + executeMultipleStatements(statements) + } + case .blocked(let reason): + if index < tabManager.tabs.count { + tabManager.tabs[index].errorMessage = reason + } } + } + } else { + if statements.count == 1 { + executeQueryInternal(statements[0]) + } else { executeMultipleStatements(statements) } } } - /// Execute table tab query directly without the Task wrapper. - /// Safe because table tab queries are always app-generated SELECTs. - /// Bypasses the 15-40ms scheduling delay of `Task { @MainActor in }`. + /// Execute table tab query directly. + /// Table tab queries are always app-generated SELECTs, so they skip dangerous-query + /// checks but still respect safe mode levels that apply to all queries. func executeTableTabQueryDirectly() { guard let index = tabManager.selectedTabIndex else { return } guard !tabManager.tabs[index].isExecuting else { return } @@ -480,7 +518,35 @@ final class MainContentCoordinator { let sql = tabManager.tabs[index].query guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - executeQueryInternal(sql) + let level = connection.safeModeLevel + if level.appliesToAllQueries && level.requiresConfirmation, + tabManager.tabs[index].lastExecutedAt == nil + { + guard !isShowingSafeModePrompt else { return } + isShowingSafeModePrompt = true + Task { @MainActor in + defer { isShowingSafeModePrompt = false } + let window = NSApp.keyWindow + let permission = await SafeModeGuard.checkPermission( + level: level, + isWriteOperation: false, + sql: sql, + operationDescription: String(localized: "Execute Query"), + window: window, + databaseType: connection.type + ) + switch permission { + case .allowed: + executeQueryInternal(sql) + case .blocked(let reason): + if index < tabManager.tabs.count { + tabManager.tabs[index].errorMessage = reason + } + } + } + } else { + executeQueryInternal(sql) + } } /// Run EXPLAIN on the current query (database-type-aware prefix) @@ -535,8 +601,26 @@ final class MainContentCoordinator { explainSQL = Self.buildRedisDebugCommand(for: stmt) } - Task { @MainActor in - executeQueryInternal(explainSQL) + let level = connection.safeModeLevel + if level.appliesToAllQueries && level.requiresConfirmation { + Task { @MainActor in + let window = NSApp.keyWindow + let permission = await SafeModeGuard.checkPermission( + level: level, + isWriteOperation: false, + sql: explainSQL, + operationDescription: String(localized: "Execute Query"), + window: window, + databaseType: connection.type + ) + if case .allowed = permission { + executeQueryInternal(explainSQL) + } + } + } else { + Task { @MainActor in + executeQueryInternal(explainSQL) + } } } @@ -998,7 +1082,7 @@ final class MainContentCoordinator { pendingDeletes: inout Set, tableOperationOptions: inout [String: TableOperationOptions] ) { - guard !connection.isReadOnly else { + guard !connection.safeModeLevel.blocksAllWrites else { if let index = tabManager.selectedTabIndex { tabManager.tabs[index].errorMessage = "Cannot save changes: connection is read-only" } @@ -1041,6 +1125,64 @@ final class MainContentCoordinator { return } + let level = connection.safeModeLevel + if level.requiresConfirmation { + let sqlPreview = allStatements.map(\.sql).joined(separator: "\n") + // Snapshot inout values before clearing — needed for executeCommitStatements + let snapshotTruncates = pendingTruncates + let snapshotDeletes = pendingDeletes + let snapshotOptions = tableOperationOptions + // Clear pending ops immediately so caller's bindings update the session. + // On cancel: restored via DatabaseManager.updateSession. + // On execution failure: restored by executeCommitStatements' existing restore logic. + if hasPendingTableOps { + pendingTruncates.removeAll() + pendingDeletes.removeAll() + for table in snapshotTruncates.union(snapshotDeletes) { + tableOperationOptions.removeValue(forKey: table) + } + } + let connId = connection.id + Task { @MainActor in + let window = NSApp.keyWindow + let permission = await SafeModeGuard.checkPermission( + level: level, + isWriteOperation: true, + sql: sqlPreview, + operationDescription: String(localized: "Save Changes"), + window: window, + databaseType: connection.type + ) + switch permission { + case .allowed: + var truncs = snapshotTruncates + var dels = snapshotDeletes + var opts = snapshotOptions + executeCommitStatements( + allStatements, + clearTableOps: hasPendingTableOps, + pendingTruncates: &truncs, + pendingDeletes: &dels, + tableOperationOptions: &opts + ) + case .blocked: + // Restore pending ops since user cancelled + if hasPendingTableOps { + DatabaseManager.shared.updateSession(connId) { session in + session.pendingTruncates = snapshotTruncates + session.pendingDeletes = snapshotDeletes + for (table, opts) in snapshotOptions { + session.tableOperationOptions[table] = opts + } + } + } + saveCompletionContinuation?.resume(returning: false) + saveCompletionContinuation = nil + } + } + return + } + // Pass statements as array to avoid SQL injection via semicolon splitting executeCommitStatements( allStatements, @@ -1099,10 +1241,6 @@ final class MainContentCoordinator { } } - // Capture inout references for async restoration via notification - // This avoids the race condition of async updateSession - let restoreNotificationName = Notification.Name("RestorePendingTableOperations_\(conn.id)") - Task { @MainActor in let overallStartTime = Date() @@ -1205,20 +1343,8 @@ final class MainContentCoordinator { window: NSApplication.shared.keyWindow ) - // Restore operations on failure so user can retry. - // Use notification to restore via MainContentView's bindings for synchronous update. + // Restore operations on failure so user can retry if clearTableOps { - NotificationCenter.default.post( - name: restoreNotificationName, - object: nil, - userInfo: [ - "truncates": truncatedTables, - "deletes": deletedTables, - "options": capturedOptions - ] - ) - - // Also update session for persistence DatabaseManager.shared.updateSession(conn.id) { session in session.pendingTruncates = truncatedTables session.pendingDeletes = deletedTables diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 59ac0414f..eae1d2d30 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -201,7 +201,7 @@ struct SidebarView: View { SidebarContextMenu( clickedTable: table, selectedTables: selectedTablesBinding, - isReadOnly: AppState.shared.isReadOnly, + isReadOnly: AppState.shared.safeModeLevel.blocksAllWrites, onBatchToggleTruncate: { viewModel.batchToggleTruncate() }, onBatchToggleDelete: { viewModel.batchToggleDelete() } ) @@ -232,7 +232,7 @@ struct SidebarView: View { SidebarContextMenu( clickedTable: nil, selectedTables: selectedTablesBinding, - isReadOnly: AppState.shared.isReadOnly, + isReadOnly: AppState.shared.safeModeLevel.blocksAllWrites, onBatchToggleTruncate: { viewModel.batchToggleTruncate() }, onBatchToggleDelete: { viewModel.batchToggleDelete() } ) diff --git a/TablePro/Views/Toolbar/ConnectionStatusView.swift b/TablePro/Views/Toolbar/ConnectionStatusView.swift index 8d348a35f..64fa57ab1 100644 --- a/TablePro/Views/Toolbar/ConnectionStatusView.swift +++ b/TablePro/Views/Toolbar/ConnectionStatusView.swift @@ -17,7 +17,7 @@ struct ConnectionStatusView: View { let connectionState: ToolbarConnectionState let displayColor: Color let tagName: String? // Tag name to avoid duplication - var isReadOnly: Bool = false + var safeModeLevel: SafeModeLevel = .silent var body: some View { HStack(spacing: 10) { @@ -61,7 +61,7 @@ struct ConnectionStatusView: View { databaseNameLabel } .buttonStyle(.plain) - .help(isReadOnly + .help(safeModeLevel == .readOnly ? String(localized: "Current database: \(databaseName) (read-only, ⌘K to switch)") : String(localized: "Current database: \(databaseName) (⌘K to switch)")) } @@ -73,12 +73,12 @@ struct ConnectionStatusView: View { .font(.system(size: 13)) .foregroundStyle(ToolbarDesignTokens.Colors.secondaryText) .overlay(alignment: .bottomTrailing) { - if isReadOnly { - Image(systemName: "lock.fill") + if safeModeLevel != .silent { + Image(systemName: safeModeLevel.iconName) .font(.system(size: 7, weight: .bold)) - .foregroundStyle(.orange) + .foregroundStyle(safeModeLevel.badgeColor) .offset(x: 3, y: 2) - .help("Read-only connection") + .help(safeModeLevel.displayName) } } diff --git a/TablePro/Views/Toolbar/SafeModeBadgeView.swift b/TablePro/Views/Toolbar/SafeModeBadgeView.swift new file mode 100644 index 000000000..6eb85448e --- /dev/null +++ b/TablePro/Views/Toolbar/SafeModeBadgeView.swift @@ -0,0 +1,57 @@ +// +// SafeModeBadgeView.swift +// TablePro +// + +import SwiftUI + +struct SafeModeBadgeView: View { + @Binding var safeModeLevel: SafeModeLevel + @State private var showPopover = false + + var body: some View { + if safeModeLevel != .silent { + Button { + showPopover.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: safeModeLevel.iconName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(safeModeLevel.badgeColor) + } + } + .buttonStyle(.plain) + .help(String(localized: "Safe Mode: \(safeModeLevel.displayName)")) + .popover(isPresented: $showPopover) { + VStack(alignment: .leading, spacing: 8) { + Text("Safe Mode") + .font(.headline) + .padding(.bottom, 4) + + Picker("", selection: $safeModeLevel) { + ForEach(SafeModeLevel.allCases) { level in + Label(level.displayName, systemImage: level.iconName) + .tag(level) + } + } + .pickerStyle(.radioGroup) + .labelsHidden() + } + .padding() + .frame(width: 200) + } + } + } +} + +// MARK: - Preview + +#Preview("Safe Mode Badges") { + VStack(spacing: 12) { + SafeModeBadgeView(safeModeLevel: .constant(.alert)) + SafeModeBadgeView(safeModeLevel: .constant(.safeMode)) + SafeModeBadgeView(safeModeLevel: .constant(.readOnly)) + } + .padding() + .background(Color(nsColor: .windowBackgroundColor)) +} diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 89384edc2..3fda55aab 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -34,9 +34,11 @@ struct ToolbarPrincipalContent: View { connectionState: state.connectionState, displayColor: state.displayColor, tagName: state.tagId.flatMap { TagStorage.shared.tag(for: $0)?.name }, - isReadOnly: state.isReadOnly + safeModeLevel: state.safeModeLevel ) + SafeModeBadgeView(safeModeLevel: Bindable(state).safeModeLevel) + ExecutionIndicatorView( isExecuting: state.isExecuting, lastDuration: state.lastQueryDuration, @@ -173,7 +175,7 @@ struct TableProToolbar: ViewModifier { Label("Import", systemImage: "square.and.arrow.down") } .help("Import Data (⌘⇧I)") - .disabled(state.connectionState != .connected || state.isReadOnly) + .disabled(state.connectionState != .connected || state.safeModeLevel.blocksAllWrites) } } } diff --git a/TableProTests/Core/Services/SafeModeGuardTests.swift b/TableProTests/Core/Services/SafeModeGuardTests.swift new file mode 100644 index 000000000..603929b41 --- /dev/null +++ b/TableProTests/Core/Services/SafeModeGuardTests.swift @@ -0,0 +1,131 @@ +// +// SafeModeGuardTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@MainActor @Suite("SafeModeGuard") +struct SafeModeGuardTests { + // MARK: - Silent level + + @Test("Silent level allows read operations") + func silentAllowsRead() async { + let result = await SafeModeGuard.checkPermission( + level: .silent, isWriteOperation: false, + sql: "SELECT * FROM users", operationDescription: "Select", + window: nil + ) + if case .blocked = result { + Issue.record("Expected .allowed but got .blocked") + } + } + + @Test("Silent level allows write operations") + func silentAllowsWrite() async { + let result = await SafeModeGuard.checkPermission( + level: .silent, isWriteOperation: true, + sql: "DROP TABLE users", operationDescription: "Drop table", + window: nil + ) + if case .blocked = result { + Issue.record("Expected .allowed but got .blocked") + } + } + + // MARK: - Read-only level + + @Test("Read-only level allows read operations") + func readOnlyAllowsRead() async { + let result = await SafeModeGuard.checkPermission( + level: .readOnly, isWriteOperation: false, + sql: "SELECT 1", operationDescription: "Select", + window: nil + ) + if case .blocked = result { + Issue.record("Expected .allowed but got .blocked") + } + } + + @Test("Read-only level blocks write operations") + func readOnlyBlocksWrite() async { + let result = await SafeModeGuard.checkPermission( + level: .readOnly, isWriteOperation: true, + sql: "DELETE FROM users", operationDescription: "Delete", + window: nil + ) + guard case let .blocked(message) = result else { + Issue.record("Expected .blocked but got .allowed") + return + } + #expect(message.contains("read-only")) + } + + // MARK: - MongoDB / Redis special handling + + @Test("Read-only blocks MongoDB even when isWriteOperation is false") + func readOnlyBlocksMongoDB() async { + let result = await SafeModeGuard.checkPermission( + level: .readOnly, isWriteOperation: false, + sql: "db.users.find({})", operationDescription: "Find", + window: nil, databaseType: .mongodb + ) + guard case let .blocked(message) = result else { + Issue.record("Expected .blocked for MongoDB but got .allowed") + return + } + #expect(message.contains("read-only")) + } + + @Test("Read-only blocks Redis even when isWriteOperation is false") + func readOnlyBlocksRedis() async { + let result = await SafeModeGuard.checkPermission( + level: .readOnly, isWriteOperation: false, + sql: "GET key", operationDescription: "Get", + window: nil, databaseType: .redis + ) + guard case let .blocked(message) = result else { + Issue.record("Expected .blocked for Redis but got .allowed") + return + } + #expect(message.contains("read-only")) + } + + @Test("Silent level allows MongoDB regardless of write flag") + func silentAllowsMongoDB() async { + let result = await SafeModeGuard.checkPermission( + level: .silent, isWriteOperation: false, + sql: "db.users.find({})", operationDescription: "Find", + window: nil, databaseType: .mongodb + ) + if case .blocked = result { + Issue.record("Expected .allowed for MongoDB in silent mode but got .blocked") + } + } + + @Test("Silent level allows Redis regardless of write flag") + func silentAllowsRedis() async { + let result = await SafeModeGuard.checkPermission( + level: .silent, isWriteOperation: false, + sql: "GET key", operationDescription: "Get", + window: nil, databaseType: .redis + ) + if case .blocked = result { + Issue.record("Expected .allowed for Redis in silent mode but got .blocked") + } + } + + @Test("Read-only allows non-MongoDB/Redis read operations with databaseType set") + func readOnlyAllowsMySQLRead() async { + let result = await SafeModeGuard.checkPermission( + level: .readOnly, isWriteOperation: false, + sql: "SELECT * FROM users", operationDescription: "Select", + window: nil, databaseType: .mysql + ) + if case .blocked = result { + Issue.record("Expected .allowed for MySQL read but got .blocked") + } + } +} diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift new file mode 100644 index 000000000..6b505cba3 --- /dev/null +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -0,0 +1,119 @@ +// +// SafeModeMigrationTests.swift +// TableProTests +// +// Tests for safeModeLevel persistence and migration from old isReadOnly format. +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("SafeModeMigration") +struct SafeModeMigrationTests { + // MARK: - Round-Trip Through ConnectionStorage API + + @Test("DatabaseConnection with silent level survives save and load cycle") + func roundTripSilent() throws { + let id = UUID() + let connection = DatabaseConnection( + id: id, name: "Silent Test", host: "127.0.0.1", port: 3306, + database: "test", username: "root", type: .mysql, + safeModeLevel: .silent + ) + + ConnectionStorage.shared.addConnection(connection) + defer { ConnectionStorage.shared.deleteConnection(connection) } + + let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + #expect(found?.safeModeLevel == .silent) + } + + @Test("DatabaseConnection with alert level survives save and load cycle") + func roundTripAlert() throws { + let id = UUID() + let connection = DatabaseConnection( + id: id, name: "Alert Test", host: "127.0.0.1", port: 5432, + database: "test", username: "postgres", type: .postgresql, + safeModeLevel: .alert + ) + + ConnectionStorage.shared.addConnection(connection) + defer { ConnectionStorage.shared.deleteConnection(connection) } + + let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + #expect(found?.safeModeLevel == .alert) + } + + @Test("DatabaseConnection with alertFull level survives save and load cycle") + func roundTripAlertFull() throws { + let id = UUID() + let connection = DatabaseConnection( + id: id, name: "AlertFull Test", host: "127.0.0.1", port: 3306, + database: "test", username: "root", type: .mysql, + safeModeLevel: .alertFull + ) + + ConnectionStorage.shared.addConnection(connection) + defer { ConnectionStorage.shared.deleteConnection(connection) } + + let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + #expect(found?.safeModeLevel == .alertFull) + } + + @Test("DatabaseConnection with safeMode level survives save and load cycle") + func roundTripSafeMode() throws { + let id = UUID() + let connection = DatabaseConnection( + id: id, name: "SafeMode Test", host: "127.0.0.1", port: 3306, + database: "test", username: "root", type: .mysql, + safeModeLevel: .safeMode + ) + + ConnectionStorage.shared.addConnection(connection) + defer { ConnectionStorage.shared.deleteConnection(connection) } + + let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + #expect(found?.safeModeLevel == .safeMode) + } + + @Test("DatabaseConnection with safeModeFull level survives save and load cycle") + func roundTripSafeModeFull() throws { + let id = UUID() + let connection = DatabaseConnection( + id: id, name: "SafeModeFull Test", host: "127.0.0.1", port: 3306, + database: "test", username: "root", type: .mysql, + safeModeLevel: .safeModeFull + ) + + ConnectionStorage.shared.addConnection(connection) + defer { ConnectionStorage.shared.deleteConnection(connection) } + + let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + #expect(found?.safeModeLevel == .safeModeFull) + } + + @Test("DatabaseConnection with readOnly level survives save and load cycle") + func roundTripReadOnly() throws { + let id = UUID() + let connection = DatabaseConnection( + id: id, name: "ReadOnly Test", host: "127.0.0.1", port: 3306, + database: "test", username: "root", type: .mysql, + safeModeLevel: .readOnly + ) + + ConnectionStorage.shared.addConnection(connection) + defer { ConnectionStorage.shared.deleteConnection(connection) } + + let found = ConnectionStorage.shared.loadConnections().first { $0.id == id } + #expect(found?.safeModeLevel == .readOnly) + } + + // MARK: - Default Level + + @Test("New connection defaults to silent safe mode level") + func defaultLevel() { + let connection = TestFixtures.makeConnection() + #expect(connection.safeModeLevel == .silent) + } +} diff --git a/TableProTests/Models/SafeModeLevelTests.swift b/TableProTests/Models/SafeModeLevelTests.swift new file mode 100644 index 000000000..5aec16d0e --- /dev/null +++ b/TableProTests/Models/SafeModeLevelTests.swift @@ -0,0 +1,165 @@ +// +// SafeModeLevelTests.swift +// TableProTests +// + +import SwiftUI +import Testing +@testable import TablePro + +@Suite("SafeModeLevel") +struct SafeModeLevelTests { + + // MARK: - Raw Values + + @Test("Raw values match expected strings") + func rawValues() { + #expect(SafeModeLevel.silent.rawValue == "silent") + #expect(SafeModeLevel.alert.rawValue == "alert") + #expect(SafeModeLevel.alertFull.rawValue == "alertFull") + #expect(SafeModeLevel.safeMode.rawValue == "safeMode") + #expect(SafeModeLevel.safeModeFull.rawValue == "safeModeFull") + #expect(SafeModeLevel.readOnly.rawValue == "readOnly") + } + + // MARK: - Identifiable + + @Test("id returns rawValue for all cases") + func idMatchesRawValue() { + for level in SafeModeLevel.allCases { + #expect(level.id == level.rawValue) + } + } + + // MARK: - CaseIterable + + @Test("allCases contains exactly 6 cases") + func allCasesCount() { + #expect(SafeModeLevel.allCases.count == 6) + } + + // MARK: - displayName + + @Test("silent displayName") + func displayNameSilent() { + #expect(SafeModeLevel.silent.displayName == String(localized: "Silent")) + } + + @Test("alert displayName") + func displayNameAlert() { + #expect(SafeModeLevel.alert.displayName == String(localized: "Alert")) + } + + @Test("alertFull displayName") + func displayNameAlertFull() { + #expect(SafeModeLevel.alertFull.displayName == String(localized: "Alert (Full)")) + } + + @Test("safeMode displayName") + func displayNameSafeMode() { + #expect(SafeModeLevel.safeMode.displayName == String(localized: "Safe Mode")) + } + + @Test("safeModeFull displayName") + func displayNameSafeModeFull() { + #expect(SafeModeLevel.safeModeFull.displayName == String(localized: "Safe Mode (Full)")) + } + + @Test("readOnly displayName") + func displayNameReadOnly() { + #expect(SafeModeLevel.readOnly.displayName == String(localized: "Read-Only")) + } + + // MARK: - blocksAllWrites + + @Test("only readOnly blocks all writes") + func blocksAllWrites() { + #expect(SafeModeLevel.silent.blocksAllWrites == false) + #expect(SafeModeLevel.alert.blocksAllWrites == false) + #expect(SafeModeLevel.alertFull.blocksAllWrites == false) + #expect(SafeModeLevel.safeMode.blocksAllWrites == false) + #expect(SafeModeLevel.safeModeFull.blocksAllWrites == false) + #expect(SafeModeLevel.readOnly.blocksAllWrites == true) + } + + // MARK: - requiresConfirmation + + @Test("alert, alertFull, safeMode, safeModeFull require confirmation") + func requiresConfirmation() { + #expect(SafeModeLevel.silent.requiresConfirmation == false) + #expect(SafeModeLevel.alert.requiresConfirmation == true) + #expect(SafeModeLevel.alertFull.requiresConfirmation == true) + #expect(SafeModeLevel.safeMode.requiresConfirmation == true) + #expect(SafeModeLevel.safeModeFull.requiresConfirmation == true) + #expect(SafeModeLevel.readOnly.requiresConfirmation == false) + } + + // MARK: - requiresAuthentication + + @Test("safeMode and safeModeFull require authentication") + func requiresAuthentication() { + #expect(SafeModeLevel.silent.requiresAuthentication == false) + #expect(SafeModeLevel.alert.requiresAuthentication == false) + #expect(SafeModeLevel.alertFull.requiresAuthentication == false) + #expect(SafeModeLevel.safeMode.requiresAuthentication == true) + #expect(SafeModeLevel.safeModeFull.requiresAuthentication == true) + #expect(SafeModeLevel.readOnly.requiresAuthentication == false) + } + + // MARK: - appliesToAllQueries + + @Test("alertFull and safeModeFull apply to all queries") + func appliesToAllQueries() { + #expect(SafeModeLevel.silent.appliesToAllQueries == false) + #expect(SafeModeLevel.alert.appliesToAllQueries == false) + #expect(SafeModeLevel.alertFull.appliesToAllQueries == true) + #expect(SafeModeLevel.safeMode.appliesToAllQueries == false) + #expect(SafeModeLevel.safeModeFull.appliesToAllQueries == true) + #expect(SafeModeLevel.readOnly.appliesToAllQueries == false) + } + + // MARK: - iconName + + @Test("each case has the correct SF Symbol icon name") + func iconNames() { + #expect(SafeModeLevel.silent.iconName == "lock.open") + #expect(SafeModeLevel.alert.iconName == "exclamationmark.triangle") + #expect(SafeModeLevel.alertFull.iconName == "exclamationmark.triangle.fill") + #expect(SafeModeLevel.safeMode.iconName == "lock.shield") + #expect(SafeModeLevel.safeModeFull.iconName == "lock.shield.fill") + #expect(SafeModeLevel.readOnly.iconName == "lock.fill") + } + + // MARK: - badgeColor + + @Test("silent badge color is secondary") + func badgeColorSilent() { + #expect(SafeModeLevel.silent.badgeColor == .secondary) + } + + @Test("alert and alertFull badge color is orange") + func badgeColorAlert() { + #expect(SafeModeLevel.alert.badgeColor == .orange) + #expect(SafeModeLevel.alertFull.badgeColor == .orange) + } + + @Test("safeMode, safeModeFull, and readOnly badge color is red") + func badgeColorSafeAndReadOnly() { + #expect(SafeModeLevel.safeMode.badgeColor == .red) + #expect(SafeModeLevel.safeModeFull.badgeColor == .red) + #expect(SafeModeLevel.readOnly.badgeColor == .red) + } + + // MARK: - Codable + + @Test("round-trips through JSON encoding and decoding") + func codableRoundTrip() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + for level in SafeModeLevel.allCases { + let data = try encoder.encode(level) + let decoded = try decoder.decode(SafeModeLevel.self, from: data) + #expect(decoded == level) + } + } +} diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index aa2d35c97..7fe9e8ac4 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -16,11 +16,11 @@ struct SaveCompletionTests { // MARK: - Helpers private func makeCoordinator( - isReadOnly: Bool = false, + safeModeLevel: SafeModeLevel = .silent, type: DatabaseType = .mysql ) -> (MainContentCoordinator, QueryTabManager, DataChangeManager) { var conn = TestFixtures.makeConnection(type: type) - conn.isReadOnly = isReadOnly + conn.safeModeLevel = safeModeLevel let state = SessionStateFactory.create(connection: conn, payload: nil) return (state.coordinator, state.tabManager, state.changeManager) } @@ -49,7 +49,7 @@ struct SaveCompletionTests { @Test("saveChanges on read-only connection sets error message") func readOnly_setsErrorMessage() { - let (coordinator, tabManager, changeManager) = makeCoordinator(isReadOnly: true) + let (coordinator, tabManager, changeManager) = makeCoordinator(safeModeLevel: .readOnly) tabManager.addTab(databaseName: "testdb") changeManager.hasChanges = true @@ -71,7 +71,7 @@ struct SaveCompletionTests { @Test("saveChanges on read-only connection does not clear changes") func readOnly_doesNotClearChanges() { - let (coordinator, _, changeManager) = makeCoordinator(isReadOnly: true) + let (coordinator, _, changeManager) = makeCoordinator(safeModeLevel: .readOnly) changeManager.hasChanges = true @@ -115,7 +115,7 @@ struct SaveCompletionTests { @Test("saveChanges with pending truncates but read-only sets error") func pendingTruncatesReadOnly_setsError() { - let (coordinator, tabManager, _) = makeCoordinator(isReadOnly: true) + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .readOnly) tabManager.addTab(databaseName: "testdb") var truncates: Set = ["users"] @@ -136,7 +136,7 @@ struct SaveCompletionTests { @Test("saveChanges with no tab selected and read-only does not crash") func noTabSelected_readOnly_doesNotCrash() { - let (coordinator, _, changeManager) = makeCoordinator(isReadOnly: true) + let (coordinator, _, changeManager) = makeCoordinator(safeModeLevel: .readOnly) changeManager.hasChanges = true var truncates: Set = [] @@ -171,4 +171,127 @@ struct SaveCompletionTests { #expect(truncates.isEmpty) #expect(deletes.isEmpty) } + + // MARK: - Safe Mode Confirmation Path + + @Test("saveChanges with alert level and pending truncates clears inout params immediately") + func alertLevel_pendingTruncates_clearsParams() { + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) + tabManager.addTab(databaseName: "testdb") + + var truncates: Set = ["users"] + var deletes: Set = [] + var options: [String: TableOperationOptions] = [:] + + coordinator.saveChanges( + pendingTruncates: &truncates, + pendingDeletes: &deletes, + tableOperationOptions: &options + ) + + // Confirmation path clears inout params before returning to prevent double-execution + #expect(truncates.isEmpty) + } + + @Test("saveChanges with safeMode level and pending deletes clears inout params") + func safeModeLevel_pendingDeletes_clearsParams() { + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .safeMode) + tabManager.addTab(databaseName: "testdb") + + var truncates: Set = [] + var deletes: Set = ["orders"] + var options: [String: TableOperationOptions] = [:] + + coordinator.saveChanges( + pendingTruncates: &truncates, + pendingDeletes: &deletes, + tableOperationOptions: &options + ) + + #expect(deletes.isEmpty) + } + + @Test("saveChanges with alert level and no changes does nothing") + func alertLevel_noChanges_noop() { + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) + tabManager.addTab(databaseName: "testdb") + + var truncates: Set = [] + var deletes: Set = [] + var options: [String: TableOperationOptions] = [:] + + coordinator.saveChanges( + pendingTruncates: &truncates, + pendingDeletes: &deletes, + tableOperationOptions: &options + ) + + #expect(tabManager.tabs.first?.errorMessage == nil) + #expect(truncates.isEmpty) + #expect(deletes.isEmpty) + } + + @Test("saveChanges with silent level and pending truncates clears via normal path") + func silentLevel_pendingTruncates_clearsViaNormalPath() { + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .silent) + tabManager.addTab(databaseName: "testdb") + + var truncates: Set = ["users"] + var deletes: Set = [] + var options: [String: TableOperationOptions] = [:] + + coordinator.saveChanges( + pendingTruncates: &truncates, + pendingDeletes: &deletes, + tableOperationOptions: &options + ) + + // Silent level takes the normal (non-confirmation) path which also clears immediately + #expect(truncates.isEmpty) + } + + // MARK: - Row Operations and Safe Mode + + @Test("row operations blocked by readOnly level") + func rowOperations_blockedByReadOnly() { + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .readOnly) + tabManager.addTab(databaseName: "testdb") + if let index = tabManager.selectedTabIndex { + tabManager.tabs[index].isEditable = true + tabManager.tabs[index].tableName = "users" + } + + var selectedRows: Set = [] + var editingCell: CellPosition? + + coordinator.addNewRow(selectedRowIndices: &selectedRows, editingCell: &editingCell) + #expect(selectedRows.isEmpty) + #expect(editingCell == nil) + + selectedRows = [0] + coordinator.deleteSelectedRows(indices: Set([0]), selectedRowIndices: &selectedRows) + #expect(selectedRows == [0]) + + selectedRows = [] + coordinator.duplicateSelectedRow(index: 0, selectedRowIndices: &selectedRows, editingCell: &editingCell) + #expect(selectedRows.isEmpty) + #expect(editingCell == nil) + } + + @Test("row operations allowed by alert level") + func rowOperations_allowedByAlertLevel() { + let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) + tabManager.addTab(databaseName: "testdb") + if let index = tabManager.selectedTabIndex { + tabManager.tabs[index].isEditable = true + tabManager.tabs[index].tableName = "users" + } + + var selectedRows: Set = [] + var editingCell: CellPosition? + + // Alert level doesn't block row staging — only gates at execution time + coordinator.addNewRow(selectedRowIndices: &selectedRows, editingCell: &editingCell) + #expect(tabManager.tabs.first?.errorMessage == nil) + } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 673636764..5264d6147 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -470,6 +470,7 @@ Resetting preferences does not affect saved connections or query history. | Auto-check updates | General | Check for updates automatically | | Share analytics | General | Share anonymous usage data | | Auto-reconnect | Connections | Reconnect on disconnect | +| Safe Mode | Connections | Per-connection query execution controls | ### Editor-Related diff --git a/docs/docs.json b/docs/docs.json index d381d641f..b63d32169 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -61,7 +61,8 @@ "features/query-history", "features/ai-chat", "features/keyboard-shortcuts", - "features/deep-links" + "features/deep-links", + "features/safe-mode" ] }, { @@ -152,7 +153,8 @@ "vi/features/query-history", "vi/features/ai-chat", "vi/features/keyboard-shortcuts", - "vi/features/deep-links" + "vi/features/deep-links", + "vi/features/safe-mode" ] }, { diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 3cd3a0370..40e7141e5 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -414,7 +414,7 @@ When no row is selected, the Cell Inspector shows table metadata: | Collation | Collation used for sorting | -Read-only connections and query results respect the read-only state. Editing is disabled in the Cell Inspector for those contexts. +Connections with Safe Mode set to Read-Only and complex query results are not editable. Editing is disabled in the Cell Inspector for those contexts. See [Safe Mode](/features/safe-mode) for details. ## Navigation diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx new file mode 100644 index 000000000..0aa682cdf --- /dev/null +++ b/docs/features/safe-mode.mdx @@ -0,0 +1,91 @@ +--- +title: Safe Mode +description: Per-connection query execution controls - from no restrictions to full read-only lockdown +--- + +# Safe Mode + +Safe Mode is a per-connection setting that controls how TablePro handles query execution. Each connection can have its own level, from unrestricted access to complete write protection. + +Set the safe mode level when creating or editing a connection in the connection form. + +## Levels + +TablePro provides 6 graduated safe mode levels: + +| Level | Icon | Write Queries | Read Queries | Authentication | +|-------|------|--------------|--------------|----------------| +| **Silent** | `lock.open` | Execute immediately | Execute immediately | None | +| **Alert** | `exclamationmark.triangle` | Confirmation dialog | Execute immediately | None | +| **Alert (Full)** | `exclamationmark.triangle.fill` | Confirmation dialog | Confirmation dialog | None | +| **Safe Mode** | `lock.shield` | Confirmation + Touch ID | Execute immediately | Touch ID / password | +| **Safe Mode (Full)** | `lock.shield.fill` | Confirmation + Touch ID | Confirmation + Touch ID | Touch ID / password | +| **Read-Only** | `lock.fill` | Blocked entirely | Execute immediately | None | + +New connections default to **Silent**. + +## How It Works + +### Silent + +No restrictions. Queries execute immediately. TablePro still shows its built-in dangerous query warning for DROP, TRUNCATE, and DELETE-without-WHERE statements. + +### Alert + +A confirmation dialog appears before executing write queries (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, etc.). The dialog shows a preview of the SQL to be executed. Read queries run without prompts. + +### Alert (Full) + +Same as Alert, but the confirmation dialog appears for ALL queries, including SELECT statements. Useful when you want to review every query before execution. + +### Safe Mode + +Like Alert, but after confirming the dialog, you must also authenticate with Touch ID or your macOS password. Falls back to system password if Touch ID is unavailable. + +### Safe Mode (Full) + +Combines Alert (Full) and Safe Mode: every query requires both a confirmation dialog and Touch ID/password authentication. + +### Read-Only + +All write operations are blocked. The UI disables: + +- Inline cell editing +- Adding, deleting, and duplicating rows +- Table truncate and drop operations +- Import functionality + +Read queries (SELECT) execute normally. + +## MongoDB and Redis + +MongoDB and Redis don't use SQL, so TablePro cannot distinguish between read and write operations by parsing the query text. All operations on these database types are treated as write operations for safe mode purposes. This means: + +- **Alert/Safe Mode**: Every query triggers the confirmation dialog +- **Read-Only**: All operations are blocked + +## Toolbar Badge + +The current safe mode level is shown as a badge in the toolbar. Click it to change the level for the active connection without opening the connection form. + +## Where Safe Mode Applies + +Safe mode gates are enforced at these points: + +- **Query execution**: Running queries from the SQL editor +- **Save changes**: Committing cell edits, row insertions, and deletions +- **Table operations**: Truncating or dropping tables from the sidebar +- **Sidebar changes**: Renaming tables, modifying structure via the sidebar + +The `silent` level preserves the existing dangerous query warning (DROP/TRUNCATE/DELETE-without-WHERE) as a safety net. + +## Related Pages + + + + How edits are tracked and committed + + + Truncate, drop, and other table-level actions + + diff --git a/docs/vi/customization/settings.mdx b/docs/vi/customization/settings.mdx index a3888737d..4ed9e8fc7 100644 --- a/docs/vi/customization/settings.mdx +++ b/docs/vi/customization/settings.mdx @@ -464,6 +464,7 @@ Khởi động lại TablePro. | Tự động kiểm tra cập nhật | General | Kiểm tra bản cập nhật tự động | | Chia sẻ phân tích | General | Chia sẻ dữ liệu ẩn danh | | Tự động kết nối lại | Connections | Kết nối lại khi ngắt | +| Safe Mode | Kết nối | Kiểm soát thực thi query theo kết nối | ### Liên quan đến Editor diff --git a/docs/vi/features/data-grid.mdx b/docs/vi/features/data-grid.mdx index 3b9fbf02d..3f080fb4f 100644 --- a/docs/vi/features/data-grid.mdx +++ b/docs/vi/features/data-grid.mdx @@ -414,7 +414,7 @@ Khi không chọn hàng, Cell Inspector hiển thị metadata bảng: | Collation | Collation sắp xếp | -Kết nối chỉ đọc và kết quả query tuân thủ trạng thái chỉ đọc. Chỉnh sửa bị tắt trong Cell Inspector cho những trường hợp đó. +Kết nối có Safe Mode đặt ở Read-Only và kết quả query phức tạp không cho phép chỉnh sửa. Chỉnh sửa bị tắt trong Cell Inspector cho những trường hợp đó. Xem [Safe Mode](/features/safe-mode) để biết chi tiết. ## Điều Hướng diff --git a/docs/vi/features/safe-mode.mdx b/docs/vi/features/safe-mode.mdx new file mode 100644 index 000000000..9cee06e7b --- /dev/null +++ b/docs/vi/features/safe-mode.mdx @@ -0,0 +1,91 @@ +--- +title: Safe Mode +description: Cài đặt kiểm soát thực thi query theo kết nối - từ không giới hạn đến chỉ đọc hoàn toàn +--- + +# Safe Mode + +Safe Mode là cài đặt theo từng kết nối, kiểm soát cách TablePro xử lý việc thực thi query. Mỗi kết nối có thể có mức riêng, từ truy cập không giới hạn đến bảo vệ ghi hoàn toàn. + +Đặt mức safe mode khi tạo hoặc chỉnh sửa kết nối trong form kết nối. + +## Các Mức + +TablePro cung cấp 6 mức safe mode: + +| Mức | Icon | Query Ghi | Query Đọc | Xác thực | +|-----|------|-----------|-----------|----------| +| **Silent** | `lock.open` | Thực thi ngay | Thực thi ngay | Không | +| **Alert** | `exclamationmark.triangle` | Hộp thoại xác nhận | Thực thi ngay | Không | +| **Alert (Full)** | `exclamationmark.triangle.fill` | Hộp thoại xác nhận | Hộp thoại xác nhận | Không | +| **Safe Mode** | `lock.shield` | Xác nhận + Touch ID | Thực thi ngay | Touch ID / mật khẩu | +| **Safe Mode (Full)** | `lock.shield.fill` | Xác nhận + Touch ID | Xác nhận + Touch ID | Touch ID / mật khẩu | +| **Read-Only** | `lock.fill` | Chặn hoàn toàn | Thực thi ngay | Không | + +Kết nối mới mặc định ở mức **Silent**. + +## Cách Hoạt Động + +### Silent + +Không giới hạn. Query thực thi ngay lập tức. TablePro vẫn hiển thị cảnh báo cho các query nguy hiểm (DROP, TRUNCATE, DELETE không có WHERE). + +### Alert + +Hộp thoại xác nhận xuất hiện trước khi thực thi query ghi (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, v.v.). Hộp thoại hiển thị bản xem trước SQL. Query đọc chạy không cần xác nhận. + +### Alert (Full) + +Giống Alert, nhưng hộp thoại xác nhận xuất hiện cho TẤT CẢ query, bao gồm cả SELECT. Hữu ích khi bạn muốn xem lại mọi query trước khi thực thi. + +### Safe Mode + +Giống Alert, nhưng sau khi xác nhận hộp thoại, bạn phải xác thực bằng Touch ID hoặc mật khẩu macOS. Tự động dùng mật khẩu hệ thống nếu Touch ID không khả dụng. + +### Safe Mode (Full) + +Kết hợp Alert (Full) và Safe Mode: mọi query đều cần cả hộp thoại xác nhận và xác thực Touch ID/mật khẩu. + +### Read-Only + +Tất cả thao tác ghi bị chặn. Giao diện tắt: + +- Chỉnh sửa ô inline +- Thêm, xóa và nhân bản hàng +- Truncate và drop bảng +- Import dữ liệu + +Query đọc (SELECT) thực thi bình thường. + +## MongoDB và Redis + +MongoDB và Redis không dùng SQL, nên TablePro không thể phân biệt thao tác đọc và ghi bằng cách phân tích query. Tất cả thao tác trên các loại database này được coi là thao tác ghi. Điều này có nghĩa: + +- **Alert/Safe Mode**: Mọi query đều hiện hộp thoại xác nhận +- **Read-Only**: Tất cả thao tác bị chặn + +## Badge Toolbar + +Mức safe mode hiện tại hiển thị dưới dạng badge trên toolbar. Click vào để thay đổi mức cho kết nối đang hoạt động mà không cần mở form kết nối. + +## Nơi Safe Mode Áp Dụng + +Safe mode được thực thi tại các điểm: + +- **Thực thi query**: Chạy query từ SQL editor +- **Lưu thay đổi**: Commit chỉnh sửa ô, thêm hàng và xóa hàng +- **Thao tác bảng**: Truncate hoặc drop bảng từ sidebar +- **Thay đổi sidebar**: Đổi tên bảng, sửa structure qua sidebar + +Mức `silent` giữ lại cảnh báo query nguy hiểm (DROP/TRUNCATE/DELETE không WHERE) như lưới an toàn. + +## Trang Liên Quan + + + + Cách chỉnh sửa được theo dõi và commit + + + Truncate, drop và các thao tác bảng khác + +