diff --git a/features/conversation-assistant/macos/Sources/Services/APIClient.swift b/features/conversation-assistant/macos/Sources/Services/APIClient.swift index 82aa0aea8..9a22980b3 100644 --- a/features/conversation-assistant/macos/Sources/Services/APIClient.swift +++ b/features/conversation-assistant/macos/Sources/Services/APIClient.swift @@ -20,6 +20,8 @@ class APIClient { // Load saved auth authToken = KeychainHelper.load(key: "authToken") deviceId = UserDefaults.standard.string(forKey: "deviceId") + + NSLog("APIClient: init - baseURL: \(baseURL), deviceId: \(deviceId ?? "nil"), authToken: \(authToken != nil ? "present" : "nil")") } func registerDevice() async throws -> (deviceId: String, code: String) { @@ -55,42 +57,39 @@ class APIClient { return (id, code) } - func verifyDevice(code: String) async throws { - let currentDeviceId: String - if let existingId = deviceId { - currentDeviceId = existingId - } else { - // Register first if no device ID - let (newId, _) = try await registerDevice() - self.deviceId = newId - currentDeviceId = newId + func checkVerification() async throws -> Bool { + guard let currentDeviceId = deviceId else { + NSLog("APIClient: checkVerification - no deviceId") + return false } - let params: [String: String] = [ - "deviceId": currentDeviceId, - "code": code - ] + let url = "\(baseURL)/api/devices/\(currentDeviceId)/status" + NSLog("APIClient: checkVerification - calling \(url)") let response = try await AF.request( - "\(baseURL)/api/devices/verify", - method: .post, - parameters: params, - encoding: JSONEncoding.default + url, + method: .get ).serializingData().value let json = try JSON(data: response) + NSLog("APIClient: checkVerification - response: \(json)") - guard json["success"].boolValue else { - throw APIError.requestFailed(json["error"]["message"].stringValue) + if json["success"].boolValue && json["data"]["isActive"].boolValue { + // Device is verified - get auth token + if let token = json["data"]["token"].string { + NSLog("APIClient: checkVerification - got token, saving to keychain") + authToken = token + KeychainHelper.save(key: "authToken", value: token) + } + return true } - - let token = json["data"]["token"].stringValue - authToken = token - KeychainHelper.save(key: "authToken", value: token) + NSLog("APIClient: checkVerification - not active") + return false } func syncMessages(_ data: SyncMessagesPayload) async throws -> Int { guard let token = authToken else { + NSLog("APIClient: syncMessages - no auth token!") throw APIError.notAuthenticated } @@ -98,21 +97,34 @@ class APIClient { "Authorization": "Bearer \(token)" ] - let response = try await AF.request( - "\(baseURL)/api/sync/messages", - method: .post, - parameters: data.dictionary, - encoding: JSONEncoding.default, - headers: headers - ).serializingData().value + let url = "\(baseURL)/api/sync/messages" + NSLog("APIClient: syncMessages - calling \(url) with \(data.messages.count) messages") - let json = try JSON(data: response) + do { + let response = try await AF.request( + url, + method: .post, + parameters: data.dictionary, + encoding: JSONEncoding.default, + headers: headers + ).serializingData().value - guard json["success"].boolValue else { - throw APIError.requestFailed(json["error"]["message"].stringValue) + let json = try JSON(data: response) + NSLog("APIClient: syncMessages - response: \(json)") + + guard json["success"].boolValue else { + let errorMsg = json["error"]["message"].stringValue + let errorCode = json["statusCode"].intValue + NSLog("APIClient: syncMessages - API error: \(errorMsg) (code: \(errorCode))") + throw APIError.requestFailed(errorMsg.isEmpty ? "Unknown API error" : errorMsg) + } + + return json["data"]["synced"].intValue + } catch { + NSLog("APIClient: syncMessages - Error: \(error)") + NSLog("APIClient: syncMessages - Error type: \(type(of: error))") + throw error } - - return json["data"]["synced"].intValue } private func getHardwareId() -> String { @@ -198,27 +210,51 @@ enum APIError: LocalizedError { } class KeychainHelper { + private static let service = "com.lilith.conversation-assistant" + static func save(key: String, value: String) { let data = value.data(using: .utf8)! - let query: [String: Any] = [ + + // First try to delete any existing item + let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: data + kSecAttrService as String: service, + kSecAttrAccount as String: key ] - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + NSLog("KeychainHelper: delete status for \(key): \(deleteStatus)") + + // Now add the new item + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + NSLog("KeychainHelper: add status for \(key): \(addStatus)") + + if addStatus != errSecSuccess { + NSLog("KeychainHelper: Failed to save \(key) - error: \(addStatus)") + } } static func load(key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? - SecItemCopyMatching(query as CFDictionary, &result) - guard let data = result as? Data else { return nil } + let status = SecItemCopyMatching(query as CFDictionary, &result) + NSLog("KeychainHelper: load status for \(key): \(status)") + + guard status == errSecSuccess, let data = result as? Data else { + return nil + } return String(data: data, encoding: .utf8) } } diff --git a/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift b/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift index 963f6d4d7..d20c25536 100644 --- a/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift +++ b/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift @@ -88,33 +88,47 @@ struct MenuBarView: View { private var authenticationView: some View { VStack(spacing: 16) { - Image(systemName: "lock.shield") + Image(systemName: "link.badge.plus") .font(.system(size: 48)) - .foregroundColor(.secondary) + .foregroundColor(.accentColor) - Text("Not Connected") + Text("Connect This Device") .font(.headline) - Text("Enter the code shown in your admin panel to connect this device.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + if viewModel.isRegistering { + ProgressView("Registering...") + } else if !viewModel.registrationCode.isEmpty { + Text("Enter this code in your admin panel:") + .font(.caption) + .foregroundColor(.secondary) - TextField("Device Code", text: $viewModel.authCode) - .textFieldStyle(.roundedBorder) - - Button(action: viewModel.authenticate) { - HStack { - if viewModel.isAuthenticating { - ProgressView() - .scaleEffect(0.7) + Text(viewModel.registrationCode) + .font(.system(size: 32, weight: .bold, design: .monospaced)) + .foregroundColor(.accentColor) + .padding(.vertical, 8) + .onTapGesture { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(viewModel.registrationCode, forType: .string) } - Text("Connect") + + Text("Tap to copy • Waiting for verification...") + .font(.caption2) + .foregroundColor(.secondary) + + Button("Get New Code") { + viewModel.refreshCode() } - .frame(maxWidth: .infinity) + .buttonStyle(.link) + } else { + Text("Unable to connect to server") + .font(.caption) + .foregroundColor(.secondary) + + Button("Retry") { + viewModel.registerDevice() + } + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) - .disabled(viewModel.authCode.isEmpty || viewModel.isAuthenticating) if let error = viewModel.authError { Text(error)