diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d88494da..835fa4192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Join a team from the activation window: paste the invite code your team owner sent instead of a license key. Owners manage members and seats from their account on tablepro.app. - Connections can take their password from 1Password (op://), HashiCorp Vault, or AWS Secrets Manager, resolved at connect time so the secret is never stored in TablePro. - Team plan: publish connections to a shared folder your team reads from, without sending passwords. Right-click a connection, then Share > Publish to Team Catalog. Teammates add the folder under Settings > Linked Folders. +- Team Library: share connections and saved queries with your team through your account instead of a shared folder. Right-click a connection and choose Share > Publish to Team Library, or publish your saved queries from the Favorites sidebar. Teammates see shared connections in their connection list and shared queries in their sidebar, and you manage the library from your account on tablepro.app. Passwords are never included. - Saved SQL queries and their folders now sync across your Macs when iCloud Sync is on. Toggle "Saved Queries" under Settings > Sync. - Recent section at the top of the sidebar tracks the last 10 tables you opened per connection and database, in every sidebar layout, and remembers them between launches. It records a table when you open it, not while you arrow through previews. Click a recent table to reopen it, or right-click to remove one or clear the list. Off by default, turn it on in Settings > General > Sidebar. (#1352) - Saving now asks you to confirm before it permanently deletes rows, so a bulk delete can't be committed by accident. (#1823) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 9b468ad93..bd763b5cf 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -112,6 +112,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { AnalyticsService.shared.startPeriodicHeartbeat() SyncCoordinator.shared.start() LinkedFolderWatcher.shared.start() + TeamLibrarySyncCoordinator.shared.start() Task { LicenseManager.shared.startPeriodicValidation() diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index d39ea08f5..02d004eb5 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -61,6 +61,8 @@ final class AppEvents { let linkedFoldersDidUpdate = PassthroughSubject() + let teamLibraryDidUpdate = PassthroughSubject() + /// Linked SQL folder rescan completed; cached file index changed. /// Senders are bulk rescans across all enabled folders, so payload is always `nil`. /// The shape is kept consistent with `sqlFavoritesDidUpdate` so subscribers can diff --git a/TablePro/Core/Services/Licensing/LicenseManager.swift b/TablePro/Core/Services/Licensing/LicenseManager.swift index c7db6fbf3..1c470e228 100644 --- a/TablePro/Core/Services/Licensing/LicenseManager.swift +++ b/TablePro/Core/Services/Licensing/LicenseManager.swift @@ -275,6 +275,8 @@ final class LicenseManager { self.license = updatedLicense evaluateStatus() + await TeamLibrarySyncCoordinator.shared.pullIfNeeded() + Self.logger.trace("License re-validated successfully") } catch { // Network failure — use grace period diff --git a/TablePro/Core/Services/TeamLibrary/LiveTeamLibraryAPIClient.swift b/TablePro/Core/Services/TeamLibrary/LiveTeamLibraryAPIClient.swift new file mode 100644 index 000000000..e637e1760 --- /dev/null +++ b/TablePro/Core/Services/TeamLibrary/LiveTeamLibraryAPIClient.swift @@ -0,0 +1,100 @@ +// +// LiveTeamLibraryAPIClient.swift +// TablePro +// +// URLSession client for the team library endpoints. Mirrors LicenseAPIClient's request pattern and +// reuses LicenseError. The encoder uses no key strategy so CodingKeys drive the snake_case wire +// format while the embedded ExportableConnection payload keeps its own encoding. +// + +import Foundation +import os + +final class LiveTeamLibraryAPIClient: TeamLibraryAPIClient { + static let shared = LiveTeamLibraryAPIClient() + + private static let logger = Logger(subsystem: "com.TablePro", category: "TeamLibraryAPIClient") + + private let baseURL = URL(string: "https://api.tablepro.app/v1/license/library")! + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(session: URLSession? = nil) { + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + config.waitsForConnectivity = true + self.session = URLSession(configuration: config) + } + } + + func pull(licenseKey: String, machineId: String) async throws -> TeamLibraryPullResponse { + try await send(method: "POST", path: "pull", body: TeamLibraryPullRequest(licenseKey: licenseKey, machineId: machineId)) + } + + func publish(_ request: TeamLibraryPublishRequest) async throws -> TeamLibraryPublishResponse { + try await send(method: "POST", path: "publish", body: request) + } + + func deleteConnection(id: String, licenseKey: String, machineId: String) async throws { + try await sendVoid(path: "connections/\(id)", licenseKey: licenseKey, machineId: machineId) + } + + func deleteQuery(clientId: String, licenseKey: String, machineId: String) async throws { + try await sendVoid(path: "queries/\(clientId)", licenseKey: licenseKey, machineId: machineId) + } + + func deleteQueryFolder(clientId: String, licenseKey: String, machineId: String) async throws { + try await sendVoid(path: "query-folders/\(clientId)", licenseKey: licenseKey, machineId: machineId) + } + + private func send(method: String, path: String, body: T) async throws -> R { + let data = try await perform(method: method, path: path, body: body) + do { + return try decoder.decode(R.self, from: data) + } catch { + throw LicenseError.decodingError(error) + } + } + + private func sendVoid(path: String, licenseKey: String, machineId: String) async throws { + _ = try await perform(method: "DELETE", path: path, body: TeamLibraryPullRequest(licenseKey: licenseKey, machineId: machineId)) + } + + private func perform(method: String, path: String, body: T) async throws -> Data { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw LicenseError.networkError(error) + } + + guard let http = response as? HTTPURLResponse else { + throw LicenseError.networkError(URLError(.badServerResponse)) + } + + guard (200...299).contains(http.statusCode) else { + let message: String + if let errorResponse = try? decoder.decode(LicenseAPIErrorResponse.self, from: data) { + message = errorResponse.message + } else { + message = HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + } + Self.logger.error("Team library request failed \(http.statusCode): \(message)") + throw LicenseError.serverError(http.statusCode, message) + } + + return data + } +} diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibraryAPIClient.swift b/TablePro/Core/Services/TeamLibrary/TeamLibraryAPIClient.swift new file mode 100644 index 000000000..5dac9951d --- /dev/null +++ b/TablePro/Core/Services/TeamLibrary/TeamLibraryAPIClient.swift @@ -0,0 +1,46 @@ +// +// TeamLibraryAPIClient.swift +// TablePro +// +// HTTP boundary for the backend-hosted team library. A protocol so the sync coordinator can be +// unit-tested against a mock without touching the network. +// + +import Foundation + +protocol TeamLibraryAPIClient: Sendable { + func pull(licenseKey: String, machineId: String) async throws -> TeamLibraryPullResponse + func publish(_ request: TeamLibraryPublishRequest) async throws -> TeamLibraryPublishResponse + func deleteConnection(id: String, licenseKey: String, machineId: String) async throws + func deleteQuery(clientId: String, licenseKey: String, machineId: String) async throws + func deleteQueryFolder(clientId: String, licenseKey: String, machineId: String) async throws +} + +final class MockTeamLibraryAPIClient: TeamLibraryAPIClient, @unchecked Sendable { + var pullResponse: TeamLibraryPullResponse = .empty + var publishResponse = TeamLibraryPublishResponse(publishedAt: "", connectionCount: 0, queryCount: 0) + var pullError: Error? + + private(set) var pullCallCount = 0 + private(set) var publishedRequests: [TeamLibraryPublishRequest] = [] + private(set) var deletedConnectionIds: [String] = [] + + func pull(licenseKey: String, machineId: String) async throws -> TeamLibraryPullResponse { + pullCallCount += 1 + if let pullError { throw pullError } + return pullResponse + } + + func publish(_ request: TeamLibraryPublishRequest) async throws -> TeamLibraryPublishResponse { + publishedRequests.append(request) + return publishResponse + } + + func deleteConnection(id: String, licenseKey: String, machineId: String) async throws { + deletedConnectionIds.append(id) + } + + func deleteQuery(clientId: String, licenseKey: String, machineId: String) async throws {} + + func deleteQueryFolder(clientId: String, licenseKey: String, machineId: String) async throws {} +} diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibraryMetadataStorage.swift b/TablePro/Core/Services/TeamLibrary/TeamLibraryMetadataStorage.swift new file mode 100644 index 000000000..9fc4fe0a4 --- /dev/null +++ b/TablePro/Core/Services/TeamLibrary/TeamLibraryMetadataStorage.swift @@ -0,0 +1,32 @@ +// +// TeamLibraryMetadataStorage.swift +// TablePro +// +// Remembers when the team library was last pulled so the app refreshes on the license revalidation +// cadence rather than on every launch. +// + +import Foundation + +enum TeamLibraryMetadataStorage { + private static let lastPullKey = "com.TablePro.teamLibrary.lastPullAt" + private static let pullInterval: TimeInterval = 7 * 24 * 60 * 60 + + static var lastPullAt: Date? { + let timestamp = UserDefaults.standard.double(forKey: lastPullKey) + return timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil + } + + static var isPullDue: Bool { + guard let lastPullAt else { return true } + return Date().timeIntervalSince(lastPullAt) >= pullInterval + } + + static func recordPull() { + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPullKey) + } + + static func reset() { + UserDefaults.standard.removeObject(forKey: lastPullKey) + } +} diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift b/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift new file mode 100644 index 000000000..c4aedcc31 --- /dev/null +++ b/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift @@ -0,0 +1,56 @@ +// +// TeamLibraryStore.swift +// TablePro +// +// Local cache of the pulled team library. Read-only content, small enough to keep as a single JSON +// file (matching how other small caches persist), guarded by an actor so writes serialize. +// + +import Foundation +import os + +actor TeamLibraryStore { + static let shared = TeamLibraryStore() + + private static let logger = Logger(subsystem: "com.TablePro", category: "TeamLibraryStore") + + private let fileURL: URL + private var cached: TeamLibraryPullResponse? + + init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let directory = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("TablePro", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + self.fileURL = directory.appendingPathComponent("team_library.json") + } + } + + func load() -> TeamLibraryPullResponse? { + if let cached { + return cached + } + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + cached = try? JSONDecoder().decode(TeamLibraryPullResponse.self, from: data) + return cached + } + + func replace(_ response: TeamLibraryPullResponse) { + cached = response + do { + try JSONEncoder().encode(response).write(to: fileURL, options: .atomic) + } catch { + Self.logger.error("Failed to cache team library: \(error.localizedDescription)") + } + } + + func clear() { + cached = .empty + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift b/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift new file mode 100644 index 000000000..f529ca5be --- /dev/null +++ b/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift @@ -0,0 +1,138 @@ +// +// TeamLibrarySyncCoordinator.swift +// TablePro +// +// Owns the app-side team library lifecycle: pulls the shared set on the license revalidation cadence, +// caches it, and publishes secret-free content through the existing export envelope. License access +// and credentials are injected so the coordinator is unit-testable without the license singleton. +// + +import Combine +import Foundation +import os +import TableProImport + +@MainActor +@Observable +final class TeamLibrarySyncCoordinator { + static let shared = TeamLibrarySyncCoordinator() + + private static let logger = Logger(subsystem: "com.TablePro", category: "TeamLibrarySyncCoordinator") + + private let apiClient: TeamLibraryAPIClient + private let store: TeamLibraryStore + private let isFeatureAvailable: @MainActor () -> Bool + private let credentialsProvider: @MainActor () -> (key: String, machineId: String)? + + private(set) var library: TeamLibraryPullResponse = .empty + private(set) var isPublishing = false + + init( + apiClient: TeamLibraryAPIClient = LiveTeamLibraryAPIClient.shared, + store: TeamLibraryStore = .shared, + isFeatureAvailable: @escaping @MainActor () -> Bool = { LicenseManager.shared.isFeatureAvailable(.teamLibrary) }, + credentialsProvider: @escaping @MainActor () -> (key: String, machineId: String)? = { + guard let key = LicenseManager.shared.license?.key else { return nil } + return (key, LicenseStorage.shared.machineId) + } + ) { + self.apiClient = apiClient + self.store = store + self.isFeatureAvailable = isFeatureAvailable + self.credentialsProvider = credentialsProvider + } + + func start() { + guard isFeatureAvailable() else { return } + Task { + if let cached = await store.load() { + library = cached + AppEvents.shared.teamLibraryDidUpdate.send() + } + await pullIfNeeded() + } + } + + func pullIfNeeded() async { + guard isFeatureAvailable(), TeamLibraryMetadataStorage.isPullDue else { return } + await pull() + } + + func pull() async { + guard isFeatureAvailable(), let credentials = credentialsProvider() else { return } + do { + let response = try await apiClient.pull(licenseKey: credentials.key, machineId: credentials.machineId) + await store.replace(response) + library = response + TeamLibraryMetadataStorage.recordPull() + AppEvents.shared.teamLibraryDidUpdate.send() + } catch { + Self.logger.warning("Team library pull failed: \(error.localizedDescription)") + } + } + + func refresh() { + Task { await pull() } + } + + @discardableResult + func publish( + connections: [DatabaseConnection], + favorites: [SQLFavorite], + folders: [SQLFavoriteFolder] + ) async throws -> TeamLibraryPublishResponse { + guard let credentials = credentialsProvider() else { + throw TeamLibraryPublishError.notLicensed + } + + isPublishing = true + defer { isPublishing = false } + + let envelope = ConnectionExportService.buildEnvelope(for: connections) + let connectionPayloads = zip(connections, envelope.connections).map { connection, exportable in + TeamLibraryConnectionPayload(sourceConnectionId: connection.id.uuidString, payload: exportable) + } + let folderPayloads = folders.map { folder in + TeamLibraryQueryFolderPayload( + clientId: folder.id.uuidString, + parentClientId: folder.parentId?.uuidString, + name: folder.name, + sortOrder: folder.sortOrder + ) + } + let queryPayloads = favorites.map { favorite in + TeamLibraryQueryPayload( + clientId: favorite.id.uuidString, + folderClientId: favorite.folderId?.uuidString, + connectionClientId: favorite.connectionId?.uuidString, + name: favorite.name, + query: favorite.query, + keyword: favorite.keyword, + sortOrder: favorite.sortOrder + ) + } + + let request = TeamLibraryPublishRequest( + licenseKey: credentials.key, + machineId: credentials.machineId, + connections: connectionPayloads, + queryFolders: folderPayloads, + queries: queryPayloads + ) + + let response = try await apiClient.publish(request) + await pull() + return response + } +} + +enum TeamLibraryPublishError: LocalizedError { + case notLicensed + + var errorDescription: String? { + switch self { + case .notLicensed: + return String(localized: "Activate a Team license to publish to the team library.") + } + } +} diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index 564ccde0b..8c44a0aaa 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -14,6 +14,7 @@ internal enum ProFeature: String, CaseIterable { case envVarReferences case linkedFolders case teamCatalog + case teamLibrary var displayName: String { switch self { @@ -27,6 +28,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Linked Folders") case .teamCatalog: return String(localized: "Team Catalog") + case .teamLibrary: + return String(localized: "Team Library") } } @@ -42,6 +45,8 @@ internal enum ProFeature: String, CaseIterable { return "folder.badge.gearshape" case .teamCatalog: return "person.2.fill" + case .teamLibrary: + return "books.vertical.fill" } } @@ -57,6 +62,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Watch shared folders for connection files.") case .teamCatalog: return String(localized: "Publish connections to a shared folder your team reads from. Passwords are never included.") + case .teamLibrary: + return String(localized: "Share connections and saved queries with your team through your account. Passwords are never included.") } } @@ -65,7 +72,7 @@ internal enum ProFeature: String, CaseIterable { switch self { case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders: return .starter - case .teamCatalog: + case .teamCatalog, .teamLibrary: return .team } } diff --git a/TablePro/Models/TeamLibrary/TeamLibraryModels.swift b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift new file mode 100644 index 000000000..c57f0bc9c --- /dev/null +++ b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift @@ -0,0 +1,164 @@ +// +// TeamLibraryModels.swift +// TablePro +// +// Transfer types for the backend-hosted team library. Requests use snake_case keys via CodingKeys +// so the embedded ExportableConnection payload keeps its own key encoding (no recursive key strategy). +// + +import Foundation +import TableProImport + +struct TeamLibraryPullRequest: Codable { + let licenseKey: String + let machineId: String + + enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case machineId = "machine_id" + } +} + +struct TeamLibraryConnectionPayload: Codable { + let sourceConnectionId: String? + let payload: ExportableConnection + + enum CodingKeys: String, CodingKey { + case sourceConnectionId = "source_connection_id" + case payload + } +} + +struct TeamLibraryQueryFolderPayload: Codable { + let clientId: String + let parentClientId: String? + let name: String + let sortOrder: Int + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case parentClientId = "parent_client_id" + case name + case sortOrder = "sort_order" + } +} + +struct TeamLibraryQueryPayload: Codable { + let clientId: String + let folderClientId: String? + let connectionClientId: String? + let name: String + let query: String + let keyword: String? + let sortOrder: Int + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case folderClientId = "folder_client_id" + case connectionClientId = "connection_client_id" + case name + case query + case keyword + case sortOrder = "sort_order" + } +} + +struct TeamLibraryPublishRequest: Codable { + let licenseKey: String + let machineId: String + let connections: [TeamLibraryConnectionPayload] + let queryFolders: [TeamLibraryQueryFolderPayload] + let queries: [TeamLibraryQueryPayload] + + enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case machineId = "machine_id" + case connections + case queryFolders = "query_folders" + case queries + } +} + +struct TeamLibraryPublishResponse: Codable { + let publishedAt: String + let connectionCount: Int + let queryCount: Int + + enum CodingKeys: String, CodingKey { + case publishedAt = "published_at" + case connectionCount = "connection_count" + case queryCount = "query_count" + } +} + +struct TeamLibraryPullResponse: Codable { + let connections: [Connection] + let queryFolders: [QueryFolder] + let queries: [Query] + let fetchedAt: String + + enum CodingKeys: String, CodingKey { + case connections + case queryFolders = "query_folders" + case queries + case fetchedAt = "fetched_at" + } + + static let empty = TeamLibraryPullResponse(connections: [], queryFolders: [], queries: [], fetchedAt: "") + + struct Connection: Codable, Identifiable { + let id: String + let sourceConnectionId: String? + let payload: ExportableConnection + let publishedBy: String? + let publishedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case sourceConnectionId = "source_connection_id" + case payload + case publishedBy = "published_by" + case publishedAt = "published_at" + } + } + + struct QueryFolder: Codable, Identifiable { + var id: String { clientId } + let clientId: String + let parentClientId: String? + let name: String + let sortOrder: Int + let publishedBy: String? + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case parentClientId = "parent_client_id" + case name + case sortOrder = "sort_order" + case publishedBy = "published_by" + } + } + + struct Query: Codable, Identifiable { + var id: String { clientId } + let clientId: String + let folderClientId: String? + let connectionClientId: String? + let name: String + let query: String + let keyword: String? + let sortOrder: Int + let publishedBy: String? + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case folderClientId = "folder_client_id" + case connectionClientId = "connection_client_id" + case name + case query + case keyword + case sortOrder = "sort_order" + case publishedBy = "published_by" + } + } +} diff --git a/TablePro/ViewModels/WelcomeViewModel+TeamLibrary.swift b/TablePro/ViewModels/WelcomeViewModel+TeamLibrary.swift new file mode 100644 index 000000000..0f2712d73 --- /dev/null +++ b/TablePro/ViewModels/WelcomeViewModel+TeamLibrary.swift @@ -0,0 +1,47 @@ +// +// WelcomeViewModel+TeamLibrary.swift +// TablePro +// +// Publishing connections to the backend-hosted team library. Credentials are never sent: the export +// envelope strips passwords, passphrases, TOTP secrets, and secure plugin fields. +// + +import AppKit + +extension WelcomeViewModel { + func publishConnectionsToTeamLibrary(_ connectionsToPublish: [DatabaseConnection]) { + guard LicenseManager.shared.isFeatureAvailable(.teamLibrary), !connectionsToPublish.isEmpty else { return } + + Task { @MainActor in + do { + let response = try await TeamLibrarySyncCoordinator.shared.publish( + connections: connectionsToPublish, + favorites: [], + folders: [] + ) + presentTeamLibrarySuccess(connectionCount: response.connectionCount) + } catch { + presentTeamLibraryError(error) + } + } + } + + private func presentTeamLibrarySuccess(connectionCount: Int) { + let alert = NSAlert() + alert.messageText = String(localized: "Published to the team library") + alert.informativeText = String( + format: String(localized: "Your team can now see %d shared connections. Passwords were not included."), + connectionCount + ) + alert.alertStyle = .informational + alert.runModal() + } + + private func presentTeamLibraryError(_ error: Error) { + let alert = NSAlert() + alert.messageText = String(localized: "Couldn't publish to the team library") + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.runModal() + } +} diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index e3187036b..de4d8bf64 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -46,6 +46,7 @@ final class WelcomeViewModel { var selectedConnectionIds: Set = [] var groups: [ConnectionGroup] = [] var linkedConnections: [LinkedConnection] = [] + var teamLibraryConnections: [LinkedConnection] = [] var showOnboarding: Bool var connectionsToDelete: [DatabaseConnection] = [] var showDeleteConfirmation = false @@ -90,6 +91,7 @@ final class WelcomeViewModel { @ObservationIgnored private var connectionUpdatedCancellable: AnyCancellable? @ObservationIgnored private var linkedFoldersCancellable: AnyCancellable? + @ObservationIgnored private var teamLibraryCancellable: AnyCancellable? @ObservationIgnored private var exportConnectionsCancellable: AnyCancellable? @ObservationIgnored private var importConnectionsCancellable: AnyCancellable? @ObservationIgnored private var importFromAppCancellable: AnyCancellable? @@ -216,8 +218,15 @@ final class WelcomeViewModel { self.linkedConnections = self.services.linkedFolderWatcher.linkedConnections } + teamLibraryCancellable = services.appEvents.teamLibraryDidUpdate + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.teamLibraryConnections = Self.buildTeamLibraryConnections() + } + loadConnections() linkedConnections = services.linkedFolderWatcher.linkedConnections + teamLibraryConnections = Self.buildTeamLibraryConnections() consumePendingRouterActions() startWelcomeRouterObservation() @@ -337,6 +346,21 @@ final class WelcomeViewModel { connectToDatabase(connection) } + private static let teamLibraryFolderId = UUID(uuidString: "00000000-0000-0000-0000-000000000000") ?? UUID() + + private static func buildTeamLibraryConnections() -> [LinkedConnection] { + guard LicenseManager.shared.isFeatureAvailable(.teamLibrary) else { return [] } + let placeholderURL = URL(fileURLWithPath: "/") + return TeamLibrarySyncCoordinator.shared.library.connections.map { connection in + LinkedConnection( + id: UUID(uuidString: connection.sourceConnectionId ?? "") ?? UUID(), + connection: connection.payload, + folderId: teamLibraryFolderId, + sourceFileURL: placeholderURL + ) + } + } + func duplicateConnection(_ connection: DatabaseConnection) { let duplicate = storage.duplicateConnection(connection) loadConnections() diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index a69cbb114..659296bfa 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -64,6 +64,17 @@ extension WelcomeWindowView { ) } } + + if LicenseManager.shared.isFeatureAvailable(.teamLibrary) { + Button { + vm.publishConnectionsToTeamLibrary(connections) + } label: { + Label( + String(format: String(localized: "Publish %d Connections to Team Library..."), connections.count), + systemImage: "books.vertical.fill" + ) + } + } } Divider() @@ -195,6 +206,14 @@ extension WelcomeWindowView { Label(String(localized: "Publish to Team Catalog..."), systemImage: "person.2.fill") } } + + if LicenseManager.shared.isFeatureAvailable(.teamLibrary) { + Button { + vm.publishConnectionsToTeamLibrary([connection]) + } label: { + Label(String(localized: "Publish to Team Library..."), systemImage: "books.vertical.fill") + } + } } Divider() diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 6e509a15f..5aee3c1ac 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -228,7 +228,7 @@ struct WelcomeWindowView: View { Divider() } ZStack { - if vm.treeItems.isEmpty && vm.linkedConnections.isEmpty && vm.favoriteConnections.isEmpty { + if vm.treeItems.isEmpty && vm.linkedConnections.isEmpty && vm.teamLibraryConnections.isEmpty && vm.favoriteConnections.isEmpty { emptyState } else { connectionList @@ -303,6 +303,8 @@ struct WelcomeWindowView: View { let showsFavoritesSection = vm.searchText.isEmpty && !vm.favoriteConnections.isEmpty let showsLinkedSection = !vm.linkedConnections.isEmpty && LicenseManager.shared.isFeatureAvailable(.linkedFolders) + let showsTeamLibrarySection = !vm.teamLibraryConnections.isEmpty + && LicenseManager.shared.isFeatureAvailable(.teamLibrary) let treeHasGroups = vm.treeItems.contains { item in if case .group = item { return true } return false @@ -342,6 +344,16 @@ struct WelcomeWindowView: View { sourceListSectionHeader(String(localized: "Linked")) } } + + if showsTeamLibrarySection { + Section { + ForEach(vm.teamLibraryConnections) { linked in + teamLibraryConnectionRow(for: linked) + } + } header: { + sourceListSectionHeader(String(localized: "Team Library")) + } + } } .listStyle(.inset) .listRowSeparator(.hidden) @@ -442,6 +454,30 @@ struct WelcomeWindowView: View { .listRowSeparator(.hidden) } + private func teamLibraryConnectionRow(for linked: LinkedConnection) -> some View { + HStack(spacing: 12) { + ZStack(alignment: .bottomTrailing) { + DatabaseType(rawValue: linked.connection.type).iconImage + .frame(width: 28, height: 28) + Image(systemName: "person.2.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .offset(x: 2, y: 2) + } + VStack(alignment: .leading, spacing: 2) { + Text(linked.connection.name) + .lineLimit(1) + Text(verbatim: linked.connection.displaySubtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .tag(linked.id) + .contentShape(Rectangle()) + .listRowSeparator(.hidden) + } + func primaryAction(for ids: Set) { guard !ids.isEmpty else { return } for connection in vm.connections where ids.contains(connection.id) { @@ -450,6 +486,9 @@ struct WelcomeWindowView: View { for linked in vm.linkedConnections where ids.contains(linked.id) { vm.connectToLinkedConnection(linked) } + for linked in vm.teamLibraryConnections where ids.contains(linked.id) { + vm.connectToLinkedConnection(linked) + } } // MARK: - Empty State diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index af587e7e1..0a226df6e 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -58,9 +58,9 @@ internal struct FavoritesTabView: View { if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty && filteredTables.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.nodes.isEmpty && filteredTables.isEmpty && searchText.isEmpty { + } else if viewModel.nodes.isEmpty && filteredTables.isEmpty && teamLibraryQueries.isEmpty && searchText.isEmpty { emptyState - } else if items.isEmpty && filteredTables.isEmpty { + } else if items.isEmpty && filteredTables.isEmpty && teamLibraryQueries.isEmpty { noMatchState } else { favoritesList(items, filteredTables: filteredTables) @@ -158,6 +158,93 @@ internal struct FavoritesTabView: View { // MARK: - List + private var teamLibraryQueries: [TeamLibraryPullResponse.Query] { + guard LicenseManager.shared.isFeatureAvailable(.teamLibrary) else { return [] } + let all = TeamLibrarySyncCoordinator.shared.library.queries + guard !searchText.isEmpty else { return all } + return all.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || $0.query.localizedCaseInsensitiveContains(searchText) + } + } + + private func teamFavorite(from query: TeamLibraryPullResponse.Query) -> SQLFavorite { + SQLFavorite( + id: UUID(), + name: query.name, + query: query.query, + keyword: nil, + folderId: nil, + connectionId: nil, + sortOrder: 0, + createdAt: Date(), + updatedAt: Date() + ) + } + + @ViewBuilder + private func teamLibrarySection() -> some View { + if !teamLibraryQueries.isEmpty { + Section(String(localized: "Team Library")) { + ForEach(teamLibraryQueries) { query in + Button { + coordinator?.runFavoriteInNewTab(teamFavorite(from: query)) + } label: { + HStack(spacing: 6) { + Image(systemName: "books.vertical") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(query.name) + if let publishedBy = query.publishedBy { + Text(publishedBy) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .buttonStyle(.plain) + } + } + } + } + + private func publishSavedQueriesToTeam() { + Task { @MainActor in + let favorites = await SQLFavoriteManager.shared.fetchFavorites() + let folders = await SQLFavoriteManager.shared.fetchFolders() + guard !favorites.isEmpty else { + presentTeamLibraryInfo(String(localized: "You have no saved queries to publish.")) + return + } + guard confirmPublishSavedQueries(count: favorites.count) else { return } + do { + _ = try await TeamLibrarySyncCoordinator.shared.publish(connections: [], favorites: favorites, folders: folders) + presentTeamLibraryInfo(String(format: String(localized: "Published %d saved queries to your team."), favorites.count)) + } catch { + presentTeamLibraryInfo(error.localizedDescription) + } + } + } + + private func confirmPublishSavedQueries(count: Int) -> Bool { + let alert = NSAlert() + alert.messageText = String(localized: "Publish saved queries to your team?") + alert.informativeText = String( + format: String(localized: "Your team will see the names and SQL of %d saved queries. This replaces what you previously published."), + count + ) + alert.addButton(withTitle: String(localized: "Publish")) + alert.addButton(withTitle: String(localized: "Cancel")) + return alert.runModal() == .alertFirstButtonReturn + } + + private func presentTeamLibraryInfo(_ message: String) { + let alert = NSAlert() + alert.messageText = String(localized: "Team Library") + alert.informativeText = message + alert.runModal() + } + private func favoritesList( _ items: [FavoriteNode], filteredTables: [TableInfo] @@ -182,6 +269,7 @@ internal struct FavoritesTabView: View { } } } + teamLibrarySection() } .sidebarListLayout() .onDeleteCommand { @@ -499,6 +587,12 @@ internal struct FavoritesTabView: View { Button(String(localized: "Add Linked SQL Folder...")) { addLinkedFolder() } + if LicenseManager.shared.isFeatureAvailable(.teamLibrary) { + Divider() + Button(String(localized: "Publish Saved Queries to Team...")) { + publishSavedQueriesToTeam() + } + } } label: { Image(systemName: "plus") } diff --git a/TableProTests/TeamLibrary/TeamLibraryModelsTests.swift b/TableProTests/TeamLibrary/TeamLibraryModelsTests.swift new file mode 100644 index 000000000..319160ea7 --- /dev/null +++ b/TableProTests/TeamLibrary/TeamLibraryModelsTests.swift @@ -0,0 +1,66 @@ +// +// TeamLibraryModelsTests.swift +// TablePro +// +// Tests the team library wire format: snake_case top-level keys, and the embedded connection payload +// keeping its own key encoding (no recursive key strategy corrupting it). +// + +import Foundation +@testable import TablePro +import TableProImport +import Testing + +@Suite("TeamLibraryModels") +struct TeamLibraryModelsTests { + @Test("pull response decodes the snake_case wire format") + func decodesPullResponse() throws { + let json = """ + { + "connections": [ + {"id":"01ABC","source_connection_id":"11111111-1111-1111-1111-111111111111","payload":{"name":"Prod","host":"db","port":5432,"database":"app","username":"deploy","type":"PostgreSQL"},"published_by":"owner@example.com","published_at":"2026-07-08T00:00:00Z"} + ], + "query_folders": [ + {"client_id":"22222222-2222-2222-2222-222222222222","parent_client_id":null,"name":"ETL","sort_order":0,"published_by":"owner@example.com"} + ], + "queries": [ + {"client_id":"33333333-3333-3333-3333-333333333333","folder_client_id":"22222222-2222-2222-2222-222222222222","connection_client_id":null,"name":"Recent","query":"select 1","keyword":null,"sort_order":0,"published_by":"owner@example.com"} + ], + "fetched_at":"2026-07-08T00:00:00Z" + } + """ + + let response = try JSONDecoder().decode(TeamLibraryPullResponse.self, from: Data(json.utf8)) + + #expect(response.connections.count == 1) + #expect(response.connections[0].payload.name == "Prod") + #expect(response.connections[0].payload.type == "PostgreSQL") + #expect(response.connections[0].publishedBy == "owner@example.com") + #expect(response.queryFolders[0].name == "ETL") + #expect(response.queries[0].query == "select 1") + } + + @Test("publish request encodes snake_case keys and preserves the payload encoding") + func encodesPublishRequest() throws { + let payloadJson = """ + {"name":"Prod","host":"db","port":5432,"database":"app","username":"deploy","type":"PostgreSQL"} + """ + let exportable = try JSONDecoder().decode(ExportableConnection.self, from: Data(payloadJson.utf8)) + + let request = TeamLibraryPublishRequest( + licenseKey: "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", + machineId: String(repeating: "a", count: 64), + connections: [TeamLibraryConnectionPayload(sourceConnectionId: "11111111-1111-1111-1111-111111111111", payload: exportable)], + queryFolders: [], + queries: [] + ) + + let string = String(decoding: try JSONEncoder().encode(request), as: UTF8.self) + + #expect(string.contains("\"license_key\"")) + #expect(string.contains("\"machine_id\"")) + #expect(string.contains("\"query_folders\"")) + #expect(string.contains("\"source_connection_id\"")) + #expect(string.contains("\"name\":\"Prod\"")) + } +} diff --git a/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift b/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift new file mode 100644 index 000000000..9f2316938 --- /dev/null +++ b/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift @@ -0,0 +1,93 @@ +// +// TeamLibrarySyncCoordinatorTests.swift +// TablePro +// +// Tests the team library coordinator against a mock client: gating, pull caching, the 7-day pull +// cadence, and that publishing sends the mapped content and refreshes. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("TeamLibrarySyncCoordinator", .serialized) +struct TeamLibrarySyncCoordinatorTests { + private func makeCoordinator( + mock: MockTeamLibraryAPIClient, + available: Bool = true + ) -> TeamLibrarySyncCoordinator { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("team_library_\(UUID().uuidString).json") + return TeamLibrarySyncCoordinator( + apiClient: mock, + store: TeamLibraryStore(fileURL: url), + isFeatureAvailable: { available }, + credentialsProvider: { ("AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", String(repeating: "a", count: 64)) } + ) + } + + @Test("pull loads the response into the observable library") + func pullUpdatesLibrary() async { + TeamLibraryMetadataStorage.reset() + let mock = MockTeamLibraryAPIClient() + mock.pullResponse = TeamLibraryPullResponse( + connections: [], + queryFolders: [], + queries: [.init(clientId: UUID().uuidString, folderClientId: nil, connectionClientId: nil, name: "Q", query: "select 1", keyword: nil, sortOrder: 0, publishedBy: "a@b.com")], + fetchedAt: "now" + ) + let coordinator = makeCoordinator(mock: mock) + + await coordinator.pull() + + #expect(coordinator.library.queries.count == 1) + #expect(mock.pullCallCount == 1) + } + + @Test("pull does nothing when the feature is unavailable") + func pullSkipsWhenUnavailable() async { + let mock = MockTeamLibraryAPIClient() + let coordinator = makeCoordinator(mock: mock, available: false) + + await coordinator.pull() + + #expect(mock.pullCallCount == 0) + } + + @Test("pullIfNeeded skips when a pull is not yet due") + func pullIfNeededRespectsInterval() async { + TeamLibraryMetadataStorage.recordPull() + let mock = MockTeamLibraryAPIClient() + let coordinator = makeCoordinator(mock: mock) + + await coordinator.pullIfNeeded() + + #expect(mock.pullCallCount == 0) + TeamLibraryMetadataStorage.reset() + } + + @Test("publish sends the mapped saved queries and then refreshes") + func publishSendsQueries() async throws { + TeamLibraryMetadataStorage.reset() + let mock = MockTeamLibraryAPIClient() + let coordinator = makeCoordinator(mock: mock) + let favorite = SQLFavorite( + id: UUID(), + name: "Recent", + query: "select 1", + keyword: nil, + folderId: nil, + connectionId: nil, + sortOrder: 0, + createdAt: Date(), + updatedAt: Date() + ) + + _ = try await coordinator.publish(connections: [], favorites: [favorite], folders: []) + + #expect(mock.publishedRequests.count == 1) + #expect(mock.publishedRequests[0].queries.count == 1) + #expect(mock.publishedRequests[0].queries[0].name == "Recent") + #expect(mock.pullCallCount == 1) + } +} diff --git a/docs/features/team.mdx b/docs/features/team.mdx index c12e5f728..b77a022c8 100644 --- a/docs/features/team.mdx +++ b/docs/features/team.mdx @@ -48,7 +48,11 @@ An invite code is single use. It seats the first Mac that redeems it. To add a s ## Share connections with your team -The Team Catalog publishes connection definitions, without passwords, to a shared folder your teammates watch. See [Team Catalog](/features/connection-sharing#team-catalog). +Two ways, depending on whether your team already shares a folder. + +**Team Library** keeps the shared set in your account, so nothing on disk needs to be shared. In the app, right-click a connection and choose **Share** > **Publish to Team Library**, or publish your saved queries from the Favorites sidebar. Passwords are never sent: only the connection definition and the query text. Teammates on the team see shared queries in their Favorites sidebar, and their app pulls the library when its license revalidates. You review and remove shared items from your account at [tablepro.app/account](https://tablepro.app/account). + +**Team Catalog** publishes connection definitions, without passwords, to a shared folder your teammates watch as a Linked Folder. Use this when your team already shares a Git repo, Dropbox, or network drive. See [Team Catalog](/features/connection-sharing#team-catalog). ## What a seat includes