diff --git a/BitwardenKit/UI/Platform/Application/Views/PillBadgeView.swift b/BitwardenKit/UI/Platform/Application/Views/PillBadgeView.swift index 68f18d23da..f84ef43e48 100644 --- a/BitwardenKit/UI/Platform/Application/Views/PillBadgeView.swift +++ b/BitwardenKit/UI/Platform/Application/Views/PillBadgeView.swift @@ -9,6 +9,9 @@ public enum PillBadgeStyle { /// A danger style with red colors. case danger + /// An info style with blue colors. + case info + /// A success style with green colors. case success @@ -20,6 +23,8 @@ public enum PillBadgeStyle { switch self { case .danger: SharedAsset.Colors.badgeDangerBackground.swiftUIColor + case .info: + SharedAsset.Colors.badgeInfoBackground.swiftUIColor case .success: SharedAsset.Colors.badgeSuccessBackground.swiftUIColor case .warning: @@ -32,6 +37,8 @@ public enum PillBadgeStyle { switch self { case .danger: SharedAsset.Colors.badgeDangerBorder.swiftUIColor + case .info: + SharedAsset.Colors.badgeInfoBorder.swiftUIColor case .success: SharedAsset.Colors.badgeSuccessBorder.swiftUIColor case .warning: @@ -44,6 +51,8 @@ public enum PillBadgeStyle { switch self { case .danger: SharedAsset.Colors.badgeDangerText.swiftUIColor + case .info: + SharedAsset.Colors.badgeInfoText.swiftUIColor case .success: SharedAsset.Colors.badgeSuccessText.swiftUIColor case .warning: diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeDangerBackground.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeDangerBackground.colorset/Contents.json index ebb716aed9..e446848128 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeDangerBackground.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeDangerBackground.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF2", - "green" : "0xF2", - "red" : "0xFE" + "blue" : "0x09", + "green" : "0x08", + "red" : "0x46" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeDangerBorder.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeDangerBorder.colorset/Contents.json index bfbad626d8..85469df4af 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeDangerBorder.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeDangerBorder.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xC9", - "green" : "0xC9", - "red" : "0xFF" + "blue" : "0x12", + "green" : "0x07", + "red" : "0x9F" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeDangerText.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeDangerText.colorset/Contents.json index 387031cfe5..a621aae52e 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeDangerText.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeDangerText.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2A", - "green" : "0x1B", - "red" : "0x95" + "blue" : "0xC9", + "green" : "0xC9", + "red" : "0xFF" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeInfoBackground.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeInfoBackground.colorset/Contents.json new file mode 100644 index 0000000000..f58f509820 --- /dev/null +++ b/BitwardenResources/Colors.xcassets/Badge/badgeInfoBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xF6", + "red" : "0xEE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x55", + "green" : "0x24", + "red" : "0x16" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeInfoBorder.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeInfoBorder.colorset/Contents.json new file mode 100644 index 0000000000..763057c2ee --- /dev/null +++ b/BitwardenResources/Colors.xcassets/Badge/badgeInfoBorder.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xDB", + "red" : "0xBE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAC", + "green" : "0x41", + "red" : "0x1A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeInfoText.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeInfoText.colorset/Contents.json new file mode 100644 index 0000000000..c3d6122dc6 --- /dev/null +++ b/BitwardenResources/Colors.xcassets/Badge/badgeInfoText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAC", + "green" : "0x41", + "red" : "0x1A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xE5", + "red" : "0xDB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBackground.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBackground.colorset/Contents.json index ad5142990a..19db0f505b 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBackground.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBackground.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF4", - "green" : "0xFD", - "red" : "0xF0" + "blue" : "0x15", + "green" : "0x2E", + "red" : "0x03" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBorder.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBorder.colorset/Contents.json index 263adbf852..1c09a7fb29 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBorder.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeSuccessBorder.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xCF", - "green" : "0xF8", - "red" : "0xB9" + "blue" : "0x30", + "green" : "0x66", + "red" : "0x01" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeSuccessText.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeSuccessText.colorset/Contents.json index 1e919c7165..0d2292bf6d 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeSuccessText.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeSuccessText.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x0F", - "green" : "0x54", - "red" : "0x08" + "blue" : "0xCF", + "green" : "0xF8", + "red" : "0xB9" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeWarningBackground.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeWarningBackground.colorset/Contents.json index d802ed9be5..817265a2b3 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeWarningBackground.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeWarningBackground.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF1", - "green" : "0xF8", - "red" : "0xFF" + "blue" : "0x00", + "green" : "0x16", + "red" : "0x44" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeWarningBorder.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeWarningBorder.colorset/Contents.json index 5999e3ec65..d7b019301e 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeWarningBorder.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeWarningBorder.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xBD", - "green" : "0xD9", - "red" : "0xFC" + "blue" : "0x03", + "green" : "0x22", + "red" : "0x8A" } }, "idiom" : "universal" diff --git a/BitwardenResources/Colors.xcassets/Badge/badgeWarningText.colorset/Contents.json b/BitwardenResources/Colors.xcassets/Badge/badgeWarningText.colorset/Contents.json index 2a11031eeb..edf84aa6ab 100644 --- a/BitwardenResources/Colors.xcassets/Badge/badgeWarningText.colorset/Contents.json +++ b/BitwardenResources/Colors.xcassets/Badge/badgeWarningText.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x33", - "red" : "0xB2" + "blue" : "0xBD", + "green" : "0xD9", + "red" : "0xFC" } }, "idiom" : "universal" diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index d151d8a6d6..af3662e2df 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -1169,6 +1169,15 @@ "LineOfCredit" = "Line of credit"; "MoneyMarket" = "Money market"; "Savings" = "Savings"; +"Devices" = "Devices"; +"NoDevicesFound" = "No devices found"; +"CurrentSession" = "Current session"; +"PendingRequest" = "Pending request"; +"Trusted" = "Trusted"; +"RecentlyActiveLabel" = "Recently active:"; +"RecentlyActiveX" = "Recently active: %1$@"; +"FirstLoginLabel" = "First login:"; +"FirstLoginX" = "First login: %1$@"; "Today" = "Today"; "ThisWeek" = "This week"; "LastWeek" = "Last week"; diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift index 59d968eae8..7f9bb66a99 100644 --- a/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift @@ -114,27 +114,4 @@ extension DeviceType { } return Localizations.deviceDisplayName(category.displayName, platform) } - - /// A match key used to correlate this device type with a pending login request's - /// `requestDeviceType` string. Differs from `platform` for extension types so that - /// e.g. `chromeExtension` ("Chrome Extension") is not confused with `chromeBrowser` - /// ("Chrome") when both are active simultaneously. - var pendingRequestMatchKey: String { - switch self { - case .chromeExtension: - "Chrome Extension" - case .firefoxExtension: - "Firefox Extension" - case .operaExtension: - "Opera Extension" - case .edgeExtension: - "Edge Extension" - case .safariExtension: - "Safari Extension" - case .vivaldiExtension: - "Vivaldi Extension" - default: - platform - } - } } diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift index 5dbb1aa672..361a88dd5c 100644 --- a/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift @@ -111,24 +111,4 @@ struct DeviceTypeCategoryDeviceTypeExtensionTests { #expect(DeviceType.server.displayName == Localizations.server) #expect(DeviceType.unknownBrowser.displayName == Localizations.unknown) } - - /// `pendingRequestMatchKey` returns the extension-specific label for extension types. - @Test(arguments: [ - (DeviceType.chromeExtension, "Chrome Extension"), - (.firefoxExtension, "Firefox Extension"), - (.operaExtension, "Opera Extension"), - (.edgeExtension, "Edge Extension"), - (.safariExtension, "Safari Extension"), - (.vivaldiExtension, "Vivaldi Extension"), - ]) - func pendingRequestMatchKey_extension(type: DeviceType, expected: String) { - #expect(type.pendingRequestMatchKey == expected) - } - - /// `pendingRequestMatchKey` falls back to `platform` for non-extension types. - @Test - func pendingRequestMatchKey_default() { - #expect(DeviceType.chromeBrowser.pendingRequestMatchKey == "Chrome") - #expect(DeviceType.iOS.pendingRequestMatchKey == "iOS") - } } diff --git a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift index 0c225e4552..9868ceca51 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift @@ -38,6 +38,9 @@ extension FeatureFlag: @retroactive CaseIterable { /// Flag to enable/disable Fill-Assist targeting rules. static let fillAssistTargetingRules = FeatureFlag(rawValue: "fill-assist-targeting-rules") + /// Feature flag for device management screen. + static let manageDevices = FeatureFlag(rawValue: "pm-4516-manage-devices") + /// Flag to enable/disable migration from My Vault Items to My Items. static let migrateMyVaultToMyItems = FeatureFlag(rawValue: "pm-20558-migrate-myvault-to-myitems") @@ -70,6 +73,7 @@ extension FeatureFlag: @retroactive CaseIterable { .deviceAuthKey, .enableCipherKeyEncryption, .fillAssistTargetingRules, + .manageDevices, .migrateMyVaultToMyItems, .newItemTypes, .noLogoutOnKdfChange, diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityAction.swift index ead5a3bc44..dc0153620f 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityAction.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityAction.swift @@ -21,6 +21,9 @@ enum AccountSecurityAction: Equatable { /// The logout button was pressed. case logout + /// The manage devices button was tapped. + case manageDevicesTapped + /// The pending login requests button was tapped. case pendingLoginRequestsTapped diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift index 2c2f741c1c..d441d8fee3 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift @@ -99,6 +99,8 @@ final class AccountSecurityProcessor: StateProcessor { + // MARK: Types + + typealias Services = HasAppIDService + & HasAuthService + & HasDeviceAPIService + & HasErrorReporter + & HasTimeProvider + + // MARK: Properties + + /// The `Coordinator` that handles navigation. + private let coordinator: AnyCoordinator + + /// The services used by the processor. + private let services: Services + + // MARK: Initialization + + /// Initializes a `DeviceManagementProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator used for navigation. + /// - services: The services used by the processor. + /// - state: The initial state of the processor. + /// + init( + coordinator: AnyCoordinator, + services: Services, + state: DeviceManagementState, + ) { + self.coordinator = coordinator + self.services = services + + super.init(state: state) + } + + // MARK: Methods + + override func perform(_ effect: DeviceManagementEffect) async { + switch effect { + case .loadData: + await loadData() + } + } + + override func receive(_ action: DeviceManagementAction) { + switch action { + case let .deviceTapped(device): + handleDeviceTapped(device) + case .dismiss: + coordinator.navigate(to: .dismiss, context: self) + case let .toastShown(newValue): + state.toast = newValue + } + } + + // MARK: Private Methods + + /// Handles when a device is tapped. + /// + /// - Parameter device: The device that was tapped. + /// + private func handleDeviceTapped(_ device: DeviceListItem) { + guard let pendingRequest = device.pendingRequest else { return } + coordinator.navigate(to: .loginRequest(pendingRequest), context: self) + } + + /// Loads the device data. + /// + private func loadData() async { + do { + // Get the current device's app ID. + let appId = await services.appIDService.getOrCreateAppID() + + // Fetch all devices and the current device in parallel. + async let devicesTask = services.deviceAPIService.getDevices() + async let currentDeviceTask = services.deviceAPIService.getCurrentDevice(appId: appId) + async let pendingRequestsTask = services.authService.getPendingLoginRequests() + + let (devices, currentDevice, pendingRequests) = try await ( + devicesTask, + currentDeviceTask, + pendingRequestsTask, + ) + + // Create device list items and mark current session. + var deviceItems = devices.map { device in + var item = DeviceListItem(device: device, timeProvider: services.timeProvider) + item.isCurrentSession = device.id == currentDevice.id + return item + } + + // Match pending requests to devices. + deviceItems = matchPendingRequestsToDevices(deviceItems, pendingRequests: pendingRequests) + + // Sort devices: current session first, then pending requests, then by activity. + deviceItems.sort { lhs, rhs in + // Current session always first. + if lhs.isCurrentSession != rhs.isCurrentSession { + return lhs.isCurrentSession + } + // Devices with pending requests second. + if lhs.hasPendingRequest != rhs.hasPendingRequest { + return lhs.hasPendingRequest + } + // Sort by last activity date descending, with nil dates last. + switch (lhs.lastActivityDate, rhs.lastActivityDate) { + case let (lhsDate?, rhsDate?): + return lhsDate > rhsDate + case (nil, _?): + return false + case (_?, nil): + return true + case (nil, nil): + // Fall back to creation date. + return lhs.firstLogin > rhs.firstLogin + } + } + + state.loadingState = .data(deviceItems) + } catch { + state.loadingState = .data([]) + await coordinator.showErrorAlert(error: error) + services.errorReporter.log(error: error) + } + } + + /// Matches the most recent pending login request to its corresponding device. + /// + /// - Parameters: + /// - devices: The list of device items. + /// - pendingRequests: The list of pending login requests. + /// - Returns: The updated list of device items with the most recent pending request matched. + /// + private func matchPendingRequestsToDevices( + _ devices: [DeviceListItem], + pendingRequests: [LoginRequest], + ) -> [DeviceListItem] { + var updatedDevices = devices + + // Sort pending requests by creation date descending to get most recent first. + let sortedRequests = pendingRequests.sorted { $0.creationDate > $1.creationDate } + + for request in sortedRequests { + guard !request.requestDeviceType.isEmpty else { continue } + // Match by platform name, skipping devices that already have a pending request + // assigned so that multiple requests for the same platform (e.g. browser and + // extension variants that both map to "Chrome") can match distinct devices. + if let index = updatedDevices.firstIndex(where: { device in + device.pendingRequest == nil && + !device.deviceType.platform.isEmpty && + device.deviceType.platform.lowercased() + == request.requestDeviceType.lowercased() + }) { + updatedDevices[index].pendingRequest = request + } + } + + return updatedDevices + } +} + +// MARK: - LoginRequestDelegate + +extension DeviceManagementProcessor: LoginRequestDelegate { + /// Update the data and display a success toast after a login request has been answered. + func loginRequestAnswered(approved: Bool) { + Task { await loadData() } + state.toast = Toast(title: approved ? Localizations.loginApproved : Localizations.logInDenied) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementProcessorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementProcessorTests.swift new file mode 100644 index 0000000000..da5d05f649 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementProcessorTests.swift @@ -0,0 +1,203 @@ +import BitwardenKit +import BitwardenKitMocks +import BitwardenResources +import Foundation +import TestHelpers +import Testing + +@testable import BitwardenShared +@testable import BitwardenSharedMocks + +// MARK: - DeviceManagementProcessorTests + +@MainActor +struct DeviceManagementProcessorTests { + // MARK: Properties + + let authService: MockAuthService + let coordinator: MockCoordinator + let deviceAPIService: MockDeviceAPIService + let errorReporter: MockErrorReporter + let timeProvider: MockTimeProvider + let subject: DeviceManagementProcessor + + // MARK: Initialization + + init() { + authService = MockAuthService() + coordinator = MockCoordinator() + deviceAPIService = MockDeviceAPIService() + errorReporter = MockErrorReporter() + timeProvider = MockTimeProvider(.mockTime(Date(timeIntervalSince1970: 1_718_000_000))) + + subject = DeviceManagementProcessor( + coordinator: coordinator.asAnyCoordinator(), + services: ServiceContainer.withMocks( + authService: authService, + deviceAPIService: deviceAPIService, + errorReporter: errorReporter, + timeProvider: timeProvider, + ), + state: DeviceManagementState(), + ) + } + + // MARK: Tests + + /// `perform(_:)` loads devices and marks the current session correctly. + @Test + func perform_loadData_success() async throws { + let currentDevice = DeviceResponse.fixture(id: "device-id-1") + let otherDevice = DeviceResponse.fixture(id: "device-id-2", isTrusted: false) + deviceAPIService.getDevicesReturnValue = [currentDevice, otherDevice] + deviceAPIService.getCurrentDeviceReturnValue = currentDevice + authService.getPendingLoginRequestResult = .success([]) + + await subject.perform(.loadData) + + let items = try #require(subject.state.loadingState.data) + #expect(items.count == 2) + #expect(items.first(where: { $0.id == "device-id-1" })?.isCurrentSession == true) + #expect(items.first(where: { $0.id == "device-id-2" })?.isCurrentSession == false) + } + + /// `perform(_:)` sorts devices: current session first, then pending requests, then by activity. + @Test + func perform_loadData_sorting() async throws { + let currentDevice = DeviceResponse.fixture( + id: "current", + lastActivityDate: Date(timeIntervalSince1970: 1_717_900_000), + ) + let pendingDevice = DeviceResponse.fixture( + id: "pending", + isTrusted: false, + lastActivityDate: Date(timeIntervalSince1970: 1_717_800_000), + type: .chromeExtension, + ) + let oldDevice = DeviceResponse.fixture( + id: "old", + isTrusted: false, + lastActivityDate: Date(timeIntervalSince1970: 1_600_000_000), + type: .macOsDesktop, + ) + let pendingRequest = LoginRequest.fixture(requestDeviceType: "Chrome") + + deviceAPIService.getDevicesReturnValue = [oldDevice, pendingDevice, currentDevice] + deviceAPIService.getCurrentDeviceReturnValue = currentDevice + authService.getPendingLoginRequestResult = .success([pendingRequest]) + + await subject.perform(.loadData) + + let items = try #require(subject.state.loadingState.data) + #expect(items.count == 3) + #expect(items[0].id == "current") // current session first + #expect(items[1].id == "pending") // pending request second + #expect(items[2].id == "old") // oldest last + } + + /// `perform(_:)` assigns the most recent pending request when multiple exist for the same platform. + @Test + func perform_loadData_matchesMostRecentPendingRequest() async throws { + let chromeDevice = DeviceResponse.fixture(id: "chrome", type: .chromeExtension) + let currentDevice = DeviceResponse.fixture(id: "current") + let olderRequest = LoginRequest.fixture( + creationDate: Date(timeIntervalSince1970: 1_000_000), + id: "older", + requestDeviceType: "Chrome", + ) + let newerRequest = LoginRequest.fixture( + creationDate: Date(timeIntervalSince1970: 2_000_000), + id: "newer", + requestDeviceType: "Chrome", + ) + + deviceAPIService.getDevicesReturnValue = [chromeDevice, currentDevice] + deviceAPIService.getCurrentDeviceReturnValue = currentDevice + authService.getPendingLoginRequestResult = .success([olderRequest, newerRequest]) + + await subject.perform(.loadData) + + let items = try #require(subject.state.loadingState.data) + let chromeItem = try #require(items.first(where: { $0.id == "chrome" })) + #expect(chromeItem.pendingRequest?.id == "newer") + } + + /// `perform(_:)` sets loading state to empty and reports the error on failure. + @Test + func perform_loadData_error() async { + deviceAPIService.getDevicesThrowableError = BitwardenTestError.example + deviceAPIService.getCurrentDeviceThrowableError = BitwardenTestError.example + authService.getPendingLoginRequestResult = .success([]) + + await subject.perform(.loadData) + + #expect(subject.state.loadingState == .data([])) + #expect(coordinator.errorAlertsShown.last as? BitwardenTestError == .example) + #expect(errorReporter.errors.last as? BitwardenTestError == .example) + } + + /// `receive(_:)` navigates to the login request when the device has a pending request. + @Test + func receive_deviceTapped_withPendingRequest() { + let request = LoginRequest.fixture() + var device = DeviceListItem.fixture() + device.pendingRequest = request + + subject.receive(.deviceTapped(device)) + + #expect(coordinator.routes.last == .loginRequest(request)) + #expect(coordinator.contexts.last is DeviceManagementProcessor) + } + + /// `receive(_:)` does nothing when the device has no pending request. + @Test + func receive_deviceTapped_noPendingRequest() { + subject.receive(.deviceTapped(.fixture())) + + #expect(coordinator.routes.isEmpty) + } + + /// `receive(_:)` dismisses the view. + @Test + func receive_dismiss() { + subject.receive(.dismiss) + + #expect(coordinator.routes.last == .dismiss) + } + + /// `receive(_:)` updates and clears the toast state. + @Test + func receive_toastShown() { + subject.receive(.toastShown(Toast(title: "test"))) + #expect(subject.state.toast == Toast(title: "test")) + + subject.receive(.toastShown(nil)) + #expect(subject.state.toast == nil) + } + + /// `loginRequestAnswered(approved:)` shows the approved toast and triggers a reload. + @Test + func loginRequestAnswered_approved() async throws { + deviceAPIService.getDevicesReturnValue = [] + deviceAPIService.getCurrentDeviceReturnValue = .fixture() + authService.getPendingLoginRequestResult = .success([]) + + subject.loginRequestAnswered(approved: true) + + #expect(subject.state.toast == Toast(title: Localizations.loginApproved)) + try await waitForAsync { deviceAPIService.getDevicesCalled } + } + + /// `loginRequestAnswered(approved:)` shows the denied toast and triggers a reload. + @Test + func loginRequestAnswered_denied() async throws { + deviceAPIService.getDevicesReturnValue = [] + deviceAPIService.getCurrentDeviceReturnValue = .fixture() + authService.getPendingLoginRequestResult = .success([]) + + subject.loginRequestAnswered(approved: false) + + #expect(subject.state.toast == Toast(title: Localizations.logInDenied)) + try await waitForAsync { deviceAPIService.getDevicesCalled } + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementState.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementState.swift new file mode 100644 index 0000000000..564ec54cc8 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementState.swift @@ -0,0 +1,15 @@ +import BitwardenKit + +// MARK: - DeviceManagementState + +/// The state used to present the `DeviceManagementView`. +/// +struct DeviceManagementState: Equatable, Sendable { + // MARK: Properties + + /// The loading state of the device management screen. + var loadingState: LoadingState<[DeviceListItem]> = .loading(nil) + + /// A toast message to show in the view. + var toast: Toast? +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementView+ViewInspectorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementView+ViewInspectorTests.swift new file mode 100644 index 0000000000..e783c39d4a --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementView+ViewInspectorTests.swift @@ -0,0 +1,58 @@ +// swiftlint:disable:this file_name +import BitwardenKit +import BitwardenKitMocks +import BitwardenResources +import ViewInspector +import XCTest + +@testable import BitwardenShared +@testable import BitwardenSharedMocks + +// MARK: - DeviceManagementViewTests + +class DeviceManagementViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: DeviceManagementView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + processor = MockProcessor(state: DeviceManagementState()) + subject = DeviceManagementView(store: Store(processor: processor)) + } + + override func tearDown() { + super.tearDown() + + processor = nil + subject = nil + } + + // MARK: Tests + + /// Tapping the cancel toolbar button dispatches the `.dismiss` action. + @MainActor + func test_cancelButton_tap() throws { + let button = try subject.inspect().findCancelToolbarButton() + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .dismiss) + } + + /// Tapping a device row with a pending request dispatches the `.deviceTapped` action. + @MainActor + func test_deviceRow_tap_withPendingRequest() throws { + let request = LoginRequest.fixture() + var device = DeviceListItem.fixture(id: "device-1") + device.pendingRequest = request + processor.state.loadingState = .data([device]) + + let button = try subject.inspect().find(viewWithAccessibilityIdentifier: "DeviceRowCell") + try button.button().tap() + + XCTAssertEqual(processor.dispatchedActions.last, .deviceTapped(device)) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementView.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementView.swift new file mode 100644 index 0000000000..3ba1bba62b --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceManagementView.swift @@ -0,0 +1,125 @@ +import BitwardenKit +import BitwardenResources +import SwiftUI + +// MARK: - DeviceManagementView + +/// A view that shows all the logged-in devices and allows the user to manage them. +/// +struct DeviceManagementView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject var store: Store + + // MARK: View + + var body: some View { + LoadingView(state: store.state.loadingState) { devices in + if devices.isEmpty { + empty + .scrollView(centerContentVertically: true) + } else { + devicesList(devices) + .scrollView() + } + } + .navigationBar(title: Localizations.devices, titleDisplayMode: .inline) + .toolbar { + cancelToolbarItem { + store.send(.dismiss) + } + } + .task { + await store.perform(.loadData) + } + .refreshable { [weak store] in + await store?.perform(.loadData) + } + .toast(store.binding( + get: \.toast, + send: DeviceManagementAction.toastShown, + )) + } + + // MARK: Private Views + + /// The empty view. + private var empty: some View { + IllustratedMessageView( + image: Asset.Images.Illustrations.devices, + message: Localizations.noDevicesFound, + ) + .frame(maxWidth: .infinity) + } + + /// The list of devices. + /// + /// - Parameter devices: The devices to display. + /// + private func devicesList(_ devices: [DeviceListItem]) -> some View { + ContentBlock(dividerLeadingPadding: 16) { + ForEach(devices) { device in + DeviceRow( + device: device, + onTap: { + store.send(.deviceTapped(device)) + }, + ) + } + } + } +} + +// MARK: - Previews + +#if DEBUG +#Preview("Empty") { + DeviceManagementView(store: Store(processor: StateProcessor(state: DeviceManagementState( + loadingState: .data([]), + )))) +} + +#Preview("Devices") { + DeviceManagementView(store: Store(processor: StateProcessor(state: DeviceManagementState( + loadingState: .data([ + DeviceListItem( + activityStatus: .today, + deviceType: .iOS, + displayName: "iPhone 15 Pro", + firstLogin: Date(), + id: "1", + identifier: "abc123", + isCurrentSession: true, + isTrusted: true, + lastActivityDate: Date(), + pendingRequest: nil, + ), + DeviceListItem( + activityStatus: .thisWeek, + deviceType: .chromeExtension, + displayName: "Chrome Extension", + firstLogin: Date().addingTimeInterval(-86400 * 30), + id: "2", + identifier: "def456", + isCurrentSession: false, + isTrusted: false, + lastActivityDate: Date().addingTimeInterval(-86400 * 3), + pendingRequest: nil, + ), + DeviceListItem( + activityStatus: .overThirtyDaysAgo, + deviceType: .macOsDesktop, + displayName: "macOS", + firstLogin: Date().addingTimeInterval(-86400 * 90), + id: "3", + identifier: "ghi789", + isCurrentSession: false, + isTrusted: true, + lastActivityDate: Date().addingTimeInterval(-86400 * 45), + pendingRequest: nil, + ), + ]), + )))) +} +#endif diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceRow.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceRow.swift new file mode 100644 index 0000000000..b52471374a --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/DeviceManagement/DeviceRow.swift @@ -0,0 +1,197 @@ +import BitwardenKit +import BitwardenResources +import SwiftUI + +// MARK: - DeviceRow + +/// A row displaying device information in the device management list. +/// +struct DeviceRow: View { + // MARK: Properties + + /// The device to display. + let device: DeviceListItem + + /// The action to perform when the device is tapped (for pending requests). + let onTap: () -> Void + + // MARK: View + + var body: some View { + if device.hasPendingRequest { + Button(action: onTap) { + rowContent + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityIdentifier("DeviceRowCell") + } else { + rowContent + .accessibilityElement(children: .combine) + .accessibilityIdentifier("DeviceRowCell") + } + } + + // MARK: Private Views + + /// The row content shared between actionable and non-actionable states. + @ViewBuilder private var rowContent: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 0) { + if device.isCurrentSession { + PillBadgeView(text: Localizations.currentSession, style: .info) + .padding(.bottom, 14) + } else if device.hasPendingRequest { + PillBadgeView(text: Localizations.pendingRequest, style: .warning) + .padding(.bottom, 14) + } + + Text(device.displayName) + .foregroundStyle(SharedAsset.Colors.textPrimary.swiftUIColor) + .styleGuide(.bodySemibold) + .accessibilityIdentifier("DeviceNameLabel") + + if device.isTrusted { + Text(Localizations.trusted) + .foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor) + .styleGuide(.subheadline) + .padding(.top, 2) + .accessibilityIdentifier("TrustedLabel") + } + + VStack(alignment: .leading, spacing: 0) { + if device.lastActivityDate != nil { + recentlyActiveRow + } + + firstLoginRow + } + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if device.hasPendingRequest { + Image(asset: SharedAsset.Icons.chevronRight16) + .imageStyle(.accessoryIcon16) + .accessibilityHidden(true) + } + } + .padding(16) + .contentShape(Rectangle()) + } + + /// The recently active row with bold label and regular status. + private var recentlyActiveRow: some View { + HStack(spacing: 4) { + Text(Localizations.recentlyActiveLabel) + .foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor) + .styleGuide(.subheadlineSemibold) + + Text(device.activityStatus.localizedString) + .foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor) + .styleGuide(.subheadline) + } + .accessibilityIdentifier("RecentlyActiveRow") + } + + /// The first login row with bold label and regular date. + private var firstLoginRow: some View { + HStack(spacing: 4) { + Text(Localizations.firstLoginLabel) + .foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor) + .styleGuide(.subheadlineSemibold) + + Text(formattedDateTime(device.firstLogin)) + .foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor) + .styleGuide(.subheadline) + } + .accessibilityIdentifier("FirstLoginRow") + } + + // MARK: Private Methods + + /// Formats a date for display with date and time. + private func formattedDateTime(_ date: Date) -> String { + DeviceRow.dateTimeFormatter.string(from: date) + } +} + +// MARK: - Private Static Helpers + +private extension DeviceRow { + /// Shared formatter for device activity dates. + static let dateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +// MARK: - Previews + +#if DEBUG +#Preview("Device Row - Current Session") { + DeviceRow( + device: DeviceListItem( + activityStatus: .today, + deviceType: .iOS, + displayName: "Mobile - iOS", + firstLogin: Date(), + id: "1", + identifier: "abc123", + isCurrentSession: true, + isTrusted: true, + lastActivityDate: Date(), + pendingRequest: nil, + ), + onTap: {}, + ) + .contentBlock() + .padding() + .background(SharedAsset.Colors.backgroundPrimary.swiftUIColor) +} + +#Preview("Device Row - Pending Request") { + DeviceRow( + device: DeviceListItem( + activityStatus: .thisWeek, + deviceType: .chromeExtension, + displayName: "Web vault - Chrome", + firstLogin: Date().addingTimeInterval(-86400 * 30), + id: "2", + identifier: "def456", + isCurrentSession: false, + isTrusted: false, + lastActivityDate: Date().addingTimeInterval(-86400 * 3), + pendingRequest: .fixture(), + ), + onTap: {}, + ) + .contentBlock() + .padding() + .background(SharedAsset.Colors.backgroundPrimary.swiftUIColor) +} + +#Preview("Device Row - Trusted Not Current") { + DeviceRow( + device: DeviceListItem( + activityStatus: .lastWeek, + deviceType: .macOsDesktop, + displayName: "Desktop - macOS", + firstLogin: Date().addingTimeInterval(-86400 * 60), + id: "3", + identifier: "ghi789", + isCurrentSession: false, + isTrusted: true, + lastActivityDate: Date().addingTimeInterval(-86400 * 10), + pendingRequest: nil, + ), + onTap: {}, + ) + .contentBlock() + .padding() + .background(SharedAsset.Colors.backgroundPrimary.swiftUIColor) +} +#endif diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift index 187c777a5d..43b74a923a 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -65,6 +65,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d typealias Services = HasASSettingsMediator & HasAccountAPIService + & HasAppIDService & HasAppInfoService & HasAuthRepository & HasAuthService @@ -72,6 +73,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d & HasBillingService & HasBiometricsRepository & HasConfigService + & HasDeviceAPIService & HasEnvironmentService & HasErrorAlertServices.ErrorAlertServices & HasErrorReporter @@ -170,6 +172,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d showAutoFill() case .deleteAccount: showDeleteAccount() + case .deviceManagement: + showDeviceManagement() case .dismiss: stackNavigator?.dismiss() case .exportVault: @@ -349,6 +353,17 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d stackNavigator?.present(DeleteAccountView(store: Store(processor: processor))) } + /// Shows the device management screen. + /// + private func showDeviceManagement() { + let processor = DeviceManagementProcessor( + coordinator: asAnyCoordinator(), + services: services, + state: DeviceManagementState(), + ) + stackNavigator?.present(DeviceManagementView(store: Store(processor: processor))) + } + /// Shows the export vault screen. /// @MainActor diff --git a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift index d261a4957e..1da9d82b21 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift @@ -32,6 +32,9 @@ public enum SettingsRoute: Equatable, Hashable { /// A route to the delete account screen. case deleteAccount + /// A route to the device management screen. + case deviceManagement + /// A route that dismisses the current view. case dismiss