diff --git a/BitwardenKit/Core/Platform/Models/Domain/SessionTimeoutPolicy.swift b/BitwardenKit/Core/Platform/Models/Domain/SessionTimeoutPolicy.swift new file mode 100644 index 0000000000..3d4e89e432 --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Domain/SessionTimeoutPolicy.swift @@ -0,0 +1,32 @@ +/// An object that represents a session timeout policy +/// +public struct SessionTimeoutPolicy { + // MARK: Properties + + /// The action to perform on session timeout. + public let timeoutAction: SessionTimeoutAction? + + /// An enumeration of session timeout types to choose from. + public let timeoutType: SessionTimeoutType? + + /// An enumeration of session timeout values to choose from. + public let timeoutValue: SessionTimeoutValue? + + // MARK: Initialization + + /// Initialize `SessionTimeoutPolicy` with the specified values. + /// + /// - Parameters: + /// - timeoutAction: The action to perform on session timeout. + /// - timeoutType: The type of session timeout. + /// - timeoutValue: The session timeout value. + public init( + timeoutAction: SessionTimeoutAction?, + timeoutType: SessionTimeoutType?, + timeoutValue: SessionTimeoutValue?, + ) { + self.timeoutAction = timeoutAction + self.timeoutType = timeoutType + self.timeoutValue = timeoutValue + } +} diff --git a/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutAction.swift b/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutAction.swift new file mode 100644 index 0000000000..4987c185ff --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutAction.swift @@ -0,0 +1,23 @@ +import BitwardenResources + +/// The action to perform on session timeout. +/// +public enum SessionTimeoutAction: Int, CaseIterable, Codable, Equatable, Menuable, Sendable { + /// Lock the vault. + case lock = 0 + + /// Log the user out. + case logout = 1 + + /// All of the cases to show in the menu. + public static let allCases: [SessionTimeoutAction] = [.lock, .logout] + + public var localizedName: String { + switch self { + case .lock: + Localizations.lock + case .logout: + Localizations.logOut + } + } +} diff --git a/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutType.swift b/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutType.swift new file mode 100644 index 0000000000..942144c975 --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutType.swift @@ -0,0 +1,93 @@ +// MARK: - SessionTimeoutType + +/// An enumeration of session timeout types to choose from. +/// +public enum SessionTimeoutType: Codable, Equatable, Hashable, Sendable { + /// Time out immediately. + case immediately + + /// Time out on app restart. + case onAppRestart + + /// Never time out the session. + case never + + /// A custom timeout value. + case custom + + // MARK: Properties + + /// The string representation of a session timeout type. + public var rawValue: String { + switch self { + case .immediately: + "immediately" + case .onAppRestart: + "onAppRestart" + case .never: + "never" + case .custom: + "custom" + } + } + + /// A safe string representation of the timeout type. + public var timeoutType: String { + switch self { + case .immediately: + "immediately" + case .onAppRestart: + "on app restart" + case .never: + "never" + case .custom: + "custom" + } + } + + // MARK: Initialization + + /// Initialize a `SessionTimeoutType` using a string of the raw value. + /// + /// - Parameter rawValue: The string representation of the type raw value. + /// + public init(rawValue: String?) { + switch rawValue { + case "custom": + self = .custom + case "immediately": + self = .immediately + case "never": + self = .never + case "onAppRestart", + "onSystemLock": + self = .onAppRestart + default: + self = .custom + } + } + + /// Initialize a `SessionTimeoutType` using a SessionTimeoutValue that belongs to that type. + /// + /// - Parameter value: The SessionTimeoutValue that belongs to the type. + /// + public init(value: SessionTimeoutValue) { + switch value { + case .custom: + self = .custom + case .immediately: + self = .immediately + case .never: + self = .never + case .onAppRestart: + self = .onAppRestart + case .fifteenMinutes, + .fiveMinutes, + .fourHours, + .oneHour, + .oneMinute, + .thirtyMinutes: + self = .custom + } + } +} diff --git a/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutTypeTests.swift b/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutTypeTests.swift new file mode 100644 index 0000000000..410a3d161a --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutTypeTests.swift @@ -0,0 +1,60 @@ +import BitwardenKit +import XCTest + +final class SessionTimeoutTypeTests: BitwardenTestCase { + // MARK: Tests + + /// `init(rawValue:)` returns the correct case for the given raw value string. + func test_initFromRawValue() { + XCTAssertEqual(SessionTimeoutType.immediately, SessionTimeoutType(rawValue: "immediately")) + XCTAssertEqual(SessionTimeoutType.onAppRestart, SessionTimeoutType(rawValue: "onAppRestart")) + // `onSystemLock` value maps to `onAppRestart` on mobile. + XCTAssertEqual(SessionTimeoutType.onAppRestart, SessionTimeoutType(rawValue: "onSystemLock")) + XCTAssertEqual(SessionTimeoutType.never, SessionTimeoutType(rawValue: "never")) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: "custom")) + } + + /// `init(rawValue:)` returns `.custom` for `nil` and unknown values (default case). + func test_initFromRawValue_defaultCase() { + // `nil` value maps to `custom` on mobile in support to legacy. + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: nil)) + // Unknown/invalid strings map to `custom` (default case). + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: "unknown")) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: "invalid")) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: "")) + } + + /// `init(value:)` returns the correct case for the given `SessionTimeoutValue`. + func test_initFromSessionTimeoutValue() { + XCTAssertEqual(SessionTimeoutType.immediately, SessionTimeoutType(value: .immediately)) + XCTAssertEqual(SessionTimeoutType.onAppRestart, SessionTimeoutType(value: .onAppRestart)) + XCTAssertEqual(SessionTimeoutType.never, SessionTimeoutType(value: .never)) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .custom(123))) + } + + /// `init(value:)` returns `.custom` for all predefined timeout values. + func test_initFromSessionTimeoutValue_predefined() { + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .oneMinute)) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .fiveMinutes)) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .fifteenMinutes)) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .thirtyMinutes)) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .oneHour)) + XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .fourHours)) + } + + /// `rawValue` returns the correct string values. + func test_rawValues() { + XCTAssertEqual(SessionTimeoutType.immediately.rawValue, "immediately") + XCTAssertEqual(SessionTimeoutType.onAppRestart.rawValue, "onAppRestart") + XCTAssertEqual(SessionTimeoutType.never.rawValue, "never") + XCTAssertEqual(SessionTimeoutType.custom.rawValue, "custom") + } + + /// `timeoutType` returns the correct string representation values. + func test_timeoutType() { + XCTAssertEqual(SessionTimeoutType.immediately.timeoutType, "immediately") + XCTAssertEqual(SessionTimeoutType.onAppRestart.timeoutType, "on app restart") + XCTAssertEqual(SessionTimeoutType.never.timeoutType, "never") + XCTAssertEqual(SessionTimeoutType.custom.timeoutType, "custom") + } +} diff --git a/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField+SnapshotTests.swift b/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField+SnapshotTests.swift new file mode 100644 index 0000000000..e01c3e9e6a --- /dev/null +++ b/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField+SnapshotTests.swift @@ -0,0 +1,113 @@ +// swiftlint:disable:this file_name +import SnapshotTesting +import SwiftUI +import XCTest + +@testable import BitwardenKit + +class SettingsPickerFieldTests: BitwardenTestCase { + // MARK: Properties + + var subject: SettingsPickerField! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + subject = nil + } + + // MARK: Tests + + /// Test a snapshot of the picker field in light mode without footer. + func disabletest_snapshot_lightMode_noFooter() { + subject = SettingsPickerField( + title: "Title", + customTimeoutValue: "1:00", + pickerValue: .constant(3600), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortrait) + } + + /// Test a snapshot of the picker field in dark mode without footer. + func disabletest_snapshot_darkMode_noFooter() { + subject = SettingsPickerField( + title: "Title", + customTimeoutValue: "1:00", + pickerValue: .constant(3600), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortraitDark) + } + + /// Test a snapshot of the picker field with large dynamic type without footer. + func disabletest_snapshot_largeDynamicType_noFooter() { + subject = SettingsPickerField( + title: "Title", + customTimeoutValue: "1:00", + pickerValue: .constant(3600), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortraitAX5) + } + + /// Test a snapshot of the picker field in light mode with footer. + func disabletest_snapshot_lightMode_withFooter() { + subject = SettingsPickerField( + title: "Title", + footer: "Your organization has set the default session timeout to 1 hour", + customTimeoutValue: "1:00", + pickerValue: .constant(3600), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortrait) + } + + /// Test a snapshot of the picker field in dark mode with footer. + func disabletest_snapshot_darkMode_withFooter() { + subject = SettingsPickerField( + title: "Title", + footer: "Your organization has set the default session timeout to 1 hour", + customTimeoutValue: "1:00", + pickerValue: .constant(3600), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortraitDark) + } + + /// Test a snapshot of the picker field with large dynamic type with footer. + func disabletest_snapshot_largeDynamicType_withFooter() { + subject = SettingsPickerField( + title: "Title", + footer: "Your organization has set the default session timeout to 1 hour", + customTimeoutValue: "1:00", + pickerValue: .constant(3600), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortraitAX5) + } + + /// Test a snapshot of the picker field with an empty title. + func disabletest_snapshot_lightMode_emptyTitle() { + subject = SettingsPickerField( + title: "", + footer: "This setting is managed by your organization", + customTimeoutValue: "1:30", + pickerValue: .constant(5400), + customTimeoutAccessibilityLabel: "one hour, thirty minutes", + ) + + assertSnapshot(of: subject, as: .defaultPortrait) + } +} diff --git a/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField+ViewInspectorTests.swift b/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField+ViewInspectorTests.swift new file mode 100644 index 0000000000..5fa4811c91 --- /dev/null +++ b/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField+ViewInspectorTests.swift @@ -0,0 +1,159 @@ +// swiftlint:disable:this file_name +import BitwardenKit +import SwiftUI +import ViewInspector +import XCTest + +class SettingsPickerFieldTests: BitwardenTestCase { + // MARK: Properties + + var pickerValue: Int! + var subject: SettingsPickerField! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + pickerValue = 3600 + } + + override func tearDown() { + super.tearDown() + pickerValue = nil + subject = nil + } + + // MARK: Tests + + /// Tests that the button exists and can be found. + func test_button_exists() throws { + subject = SettingsPickerField( + title: "Custom", + customTimeoutValue: "1:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + XCTAssertNoThrow(try subject.inspect().find(ViewType.Button.self)) + } + + /// Tests that the custom timeout value is displayed. + func test_customTimeoutValue_displays() throws { + subject = SettingsPickerField( + title: "Custom", + customTimeoutValue: "2:30", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "two hours, thirty minutes", + ) + + let text = try subject.inspect().find(text: "2:30") + XCTAssertEqual(try text.string(), "2:30") + } + + /// Tests that the title is displayed. + func test_title_displays() throws { + subject = SettingsPickerField( + title: "Custom Timeout", + customTimeoutValue: "1:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + let text = try subject.inspect().find(text: "Custom Timeout") + XCTAssertEqual(try text.string(), "Custom Timeout") + } + + /// Tests that the picker value binding works correctly. + func test_pickerValue_binding() { + let testValue = 7200 // 2 hours + subject = SettingsPickerField( + title: "Custom", + customTimeoutValue: "2:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "two hours, zero minutes", + ) + + // Update the bound value + pickerValue = testValue + XCTAssertEqual(pickerValue, testValue) + } + + /// Tests that footer text is displayed when provided. + func test_footer_displays() throws { + let footerMessage = "Your organization has set the default session timeout to 1 hour" + subject = SettingsPickerField( + title: "Custom", + footer: footerMessage, + customTimeoutValue: "1:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + let footerText = try subject.inspect().find(text: footerMessage) + XCTAssertEqual(try footerText.string(), footerMessage) + } + + /// Tests that the view renders without a footer. + func test_noFooter_renders() throws { + subject = SettingsPickerField( + title: "Custom", + footer: nil, + customTimeoutValue: "1:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + // Should not throw when inspecting + XCTAssertNoThrow(try subject.inspect().vStack()) + } + + /// Tests that a divider exists when footer is provided. + func test_divider_existsWithFooter() throws { + subject = SettingsPickerField( + title: "Custom", + footer: "Footer message", + customTimeoutValue: "1:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + XCTAssertNoThrow(try subject.inspect().find(ViewType.Divider.self)) + } + + /// Tests that a divider does not exist when footer is not provided. + func test_divider_doesNotExistWithoutFooter() throws { + subject = SettingsPickerField( + title: "Custom", + footer: nil, + customTimeoutValue: "1:00", + pickerValue: Binding( + get: { self.pickerValue }, + set: { self.pickerValue = $0 }, + ), + customTimeoutAccessibilityLabel: "one hour, zero minutes", + ) + + XCTAssertThrowsError(try subject.inspect().find(ViewType.Divider.self)) + } +} diff --git a/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField.swift b/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField.swift index 432ac2a291..dab875eac2 100644 --- a/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField.swift +++ b/BitwardenKit/UI/Platform/Application/Views/SettingsPickerField.swift @@ -14,8 +14,8 @@ public struct SettingsPickerField: View { /// The custom session timeout value. let customTimeoutValue: String - /// Whether the menu field should have a bottom divider. - let hasDivider: Bool + /// The footer text displayed below the toggle. + let footer: String? /// The date picker value. @Binding var pickerValue: Int @@ -54,7 +54,7 @@ public struct SettingsPickerField: View { .padding(.horizontal, 16) } - if hasDivider { + if footer != nil { Divider() .padding(.leading, 16) } @@ -63,11 +63,18 @@ public struct SettingsPickerField: View { CountdownDatePicker(duration: $pickerValue) .frame(maxWidth: .infinity) - if hasDivider { + if footer != nil { Divider() .padding(.leading, 16) } } + + if let footer { + Text(footer) + .styleGuide(.subheadline) + .foregroundColor(Color(asset: SharedAsset.Colors.textSecondary)) + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + } } .background(SharedAsset.Colors.backgroundSecondary.swiftUIColor) } @@ -78,21 +85,21 @@ public struct SettingsPickerField: View { /// /// - Parameters: /// - title: The title of the field. + /// - footer: The footer text displayed below the menu field. /// - customTimeoutValue: The custom session timeout value. /// - pickerValue: The date picker value. - /// - hasDivider: Whether or not the field has a bottom edge divider. /// - customTimeoutAccessibilityLabel: The accessibility label used for the custom timeout value. /// public init( title: String, + footer: String? = nil, customTimeoutValue: String, pickerValue: Binding, - hasDivider: Bool = true, - customTimeoutAccessibilityLabel: String, + customTimeoutAccessibilityLabel: String ) { self.customTimeoutAccessibilityLabel = customTimeoutAccessibilityLabel self.customTimeoutValue = customTimeoutValue - self.hasDivider = hasDivider + self.footer = footer _pickerValue = pickerValue self.title = title } @@ -103,6 +110,7 @@ public struct SettingsPickerField: View { #Preview { SettingsPickerField( title: "Custom", + footer: nil, customTimeoutValue: "1:00", pickerValue: .constant(1), customTimeoutAccessibilityLabel: "one hour, zero minutes", diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_darkMode_noFooter.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_darkMode_noFooter.1.png new file mode 100644 index 0000000000..3349705bc4 Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_darkMode_noFooter.1.png differ diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_darkMode_withFooter.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_darkMode_withFooter.1.png new file mode 100644 index 0000000000..af6419d6c8 Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_darkMode_withFooter.1.png differ diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_largeDynamicType_noFooter.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_largeDynamicType_noFooter.1.png new file mode 100644 index 0000000000..ffb2bc08bb Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_largeDynamicType_noFooter.1.png differ diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_largeDynamicType_withFooter.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_largeDynamicType_withFooter.1.png new file mode 100644 index 0000000000..8d257b3c71 Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_largeDynamicType_withFooter.1.png differ diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_emptyTitle.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_emptyTitle.1.png new file mode 100644 index 0000000000..aa60861d24 Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_emptyTitle.1.png differ diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_noFooter.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_noFooter.1.png new file mode 100644 index 0000000000..00791c0188 Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_noFooter.1.png differ diff --git a/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_withFooter.1.png b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_withFooter.1.png new file mode 100644 index 0000000000..7c2f308366 Binary files /dev/null and b/BitwardenKit/UI/Platform/Application/Views/__Snapshots__/SettingsPickerField+SnapshotTests/test_snapshot_lightMode_withFooter.1.png differ diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index 69942a52ce..893fb857c1 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -609,9 +609,6 @@ "Fido2ReturnToApp" = "Return to app"; "Fido2CheckBrowser" = "Please make sure your default browser supports WebAuthn and try again."; "ResetPasswordAutoEnrollInviteWarning" = "This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password."; -"VaultTimeoutPolicyInEffect" = "Your organization policies have set your maximum allowed vault timeout to %1$@ hour(s) and %2$@ minute(s)."; -"VaultTimeoutPolicyWithActionInEffect" = "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is %1$@ hour(s) and %2$@ minute(s). Your vault timeout action is set to %3$@."; -"VaultTimeoutActionPolicyInEffect" = "Your organization policies have set your vault timeout action to %1$@."; "VaultTimeoutToLarge" = "Your vault timeout exceeds the restrictions set by your organization."; "DisablePersonalVaultExportPolicyInEffect" = "One or more organization policies prevents your from exporting your individual vault."; "AddAccount" = "Add account"; @@ -1275,3 +1272,6 @@ "TheNewRecommendedEncryptionSettingsDescriptionLong" = "The new recommended encryption settings will improve your account security. Enter your master password to update now."; "Updating" = "Updating…"; "EncryptionSettingsUpdated" = "Encryption settings updated"; +"ThisSettingIsManagedByYourOrganization" = "This setting is managed by your organization."; +"YourOrganizationHasSetTheDefaultSessionTimeoutToX" = "Your organization has set the default session timeout to %1$@."; +"YourOrganizationHasSetTheDefaultSessionTimeoutToXAndY" = "Your organization has set the default session timeout to %1$@ and %2$@."; diff --git a/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift b/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift index f61ef09265..17ac928159 100644 --- a/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift +++ b/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift @@ -30,6 +30,9 @@ enum PolicyOptionType: String { /// A policy option for the minimum number of special characters. case minSpecial + /// A policy option for the vault timeout type. + case type + /// A policy option for whether to include lowercase characters. case useLower diff --git a/BitwardenShared/Core/Vault/Services/PolicyService.swift b/BitwardenShared/Core/Vault/Services/PolicyService.swift index 55bb2c01d5..06daf984b2 100644 --- a/BitwardenShared/Core/Vault/Services/PolicyService.swift +++ b/BitwardenShared/Core/Vault/Services/PolicyService.swift @@ -13,12 +13,12 @@ protocol PolicyService: AnyObject { /// func applyPasswordGenerationPolicy(options: inout PasswordGenerationOptions) async throws -> Bool - /// If the policy for a maximum vault timeout value is enabled, - /// return the value and action to take upon timeout. + /// Fetches the maximum vault timeout policy values if the policy is enabled. /// - /// - Returns: The timeout value in minutes, and the action to take upon timeout. + /// - Returns: A `SessionTimeoutPolicy` containing the policy's timeout action, type, and value, + /// or `nil` if no maximum vault timeout policies apply to the user. /// - func fetchTimeoutPolicyValues() async throws -> (action: SessionTimeoutAction?, value: Int)? + func fetchTimeoutPolicyValues() async throws -> SessionTimeoutPolicy? /// Go through current users policy, filter them and build a master password policy options based on enabled policy. /// - Returns: Optional `MasterPasswordPolicyOptions` if it exist. @@ -266,25 +266,39 @@ extension DefaultPolicyService { return true } - func fetchTimeoutPolicyValues() async throws -> (action: SessionTimeoutAction?, value: Int)? { + func fetchTimeoutPolicyValues() async throws -> SessionTimeoutPolicy? { let policies = await policiesApplyingToUser(.maximumVaultTimeout) guard !policies.isEmpty else { return nil } var timeoutAction: SessionTimeoutAction? - var timeoutValue = 0 + var timeoutType: SessionTimeoutType? + var timeoutValue: SessionTimeoutValue? for policy in policies { guard let policyTimeoutValue = policy[.minutes]?.intValue else { continue } - timeoutValue = policyTimeoutValue + timeoutValue = SessionTimeoutValue(rawValue: policyTimeoutValue) + + // Legacy servers may not send this value. + // In that case, we will present to the user the custom type. + if policy[.type] != nil { + timeoutType = SessionTimeoutType(rawValue: policy[.type]?.stringValue) + } // If the policy's timeout action is not lock or logOut, there is no policy timeout action. // In that case, we would present both timeout action options to the user. guard let action = policy[.action]?.stringValue, action == "lock" || action == "logOut" else { - return (nil, timeoutValue) + return SessionTimeoutPolicy(timeoutAction: nil, timeoutType: timeoutType, timeoutValue: timeoutValue) + } + switch action { + case "lock": + timeoutAction = .lock + case "logOut": + timeoutAction = .logout + default: + timeoutAction = nil } - timeoutAction = action == "lock" ? .lock : .logout } - return (timeoutAction, timeoutValue) + return SessionTimeoutPolicy(timeoutAction: timeoutAction, timeoutType: timeoutType, timeoutValue: timeoutValue) } func getMasterPasswordPolicyOptions() async throws -> MasterPasswordPolicyOptions? { diff --git a/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift b/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift index ef11f9627f..dd635d3523 100644 --- a/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift @@ -30,8 +30,9 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod let maximumTimeoutPolicy = Policy.fixture( data: [ - PolicyOptionType.minutes.rawValue: .int(60), PolicyOptionType.action.rawValue: .string("lock"), + PolicyOptionType.minutes.rawValue: .int(60), + PolicyOptionType.type.rawValue: .string("custom"), ], type: .maximumVaultTimeout, ) @@ -40,6 +41,15 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod data: [PolicyOptionType.minutes.rawValue: .int(60)], type: .maximumVaultTimeout, ) + + let maximumTimeoutPolicyLogout = Policy.fixture( + data: [ + PolicyOptionType.action.rawValue: .string("logOut"), + PolicyOptionType.minutes.rawValue: .int(60), + PolicyOptionType.type.rawValue: .string("custom"), + ], + type: .maximumVaultTimeout, + ) let passwordGeneratorPolicy = Policy.fixture( data: [ @@ -387,8 +397,21 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod let policyValues = try await subject.fetchTimeoutPolicyValues() - XCTAssertEqual(policyValues?.value, 60) - XCTAssertEqual(policyValues?.action, .lock) + XCTAssertEqual(policyValues?.timeoutValue?.rawValue, 60) + XCTAssertEqual(policyValues?.timeoutAction, .lock) + } + + /// `fetchTimeoutPolicyValues()` fetches timeout values when the policy contains data. + func test_fetchTimeoutPolicyValues_logout() async throws { + stateService.activeAccount = .fixture() + organizationService.fetchAllOrganizationsResult = .success([.fixture()]) + policyDataStore.fetchPoliciesResult = .success([maximumTimeoutPolicyLogout]) + + let policyValues = try await subject.fetchTimeoutPolicyValues() + + XCTAssertEqual(policyValues?.timeoutAction, .logout) + XCTAssertEqual(policyValues?.timeoutType, .custom) + XCTAssertEqual(policyValues?.timeoutValue?.rawValue, 60) } /// `fetchTimeoutPolicyValues()` returns `nil` if the user is exempt from policies in the organization. @@ -411,8 +434,8 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod let policyValues = try await subject.fetchTimeoutPolicyValues() - XCTAssertEqual(policyValues?.value, 60) - XCTAssertNil(policyValues?.action) + XCTAssertEqual(policyValues?.timeoutValue?.rawValue, 60) + XCTAssertNil(policyValues?.timeoutAction) } /// `organizationsApplyingPolicyToUser(_:)` returns the organization IDs which apply the policy. diff --git a/BitwardenShared/Core/Vault/Services/SyncService.swift b/BitwardenShared/Core/Vault/Services/SyncService.swift index 15f40538b8..8e3607ae56 100644 --- a/BitwardenShared/Core/Vault/Services/SyncService.swift +++ b/BitwardenShared/Core/Vault/Services/SyncService.swift @@ -454,18 +454,27 @@ extension DefaultSyncService { private func checkVaultTimeoutPolicy() async throws { guard let timeoutPolicyValues = try await policyService.fetchTimeoutPolicyValues() else { return } - let action = timeoutPolicyValues.action - let value = timeoutPolicyValues.value + let action = timeoutPolicyValues.timeoutAction + let type = timeoutPolicyValues.timeoutType + guard let value = timeoutPolicyValues.timeoutValue?.rawValue else { return } let timeoutAction = try await stateService.getTimeoutAction() let timeoutValue = try await stateService.getVaultTimeout() - // Only update the user's stored vault timeout value if - // their stored timeout value is > the policy's timeout value. - if timeoutValue.rawValue > value || timeoutValue.rawValue < 0 { + // For onAppRestart and never policy types, preserve the user's current timeout value + // as these policy types don't restrict the value itself, only the behavior + if type == SessionTimeoutType.onAppRestart || type == SessionTimeoutType.never { try await stateService.setVaultTimeout( - value: SessionTimeoutValue(rawValue: value), + value: timeoutValue, ) + } else { + // Only update the user's stored vault timeout value if + // their stored timeout value is > the policy's timeout value. + if timeoutValue.rawValue > value || timeoutValue.rawValue < 0 { + try await stateService.setVaultTimeout( + value: SessionTimeoutValue(rawValue: value), + ) + } } try await stateService.setTimeoutAction(action: action ?? timeoutAction) diff --git a/BitwardenShared/Core/Vault/Services/SyncServiceTests.swift b/BitwardenShared/Core/Vault/Services/SyncServiceTests.swift index 374eb0fa7e..9c8738bae3 100644 --- a/BitwardenShared/Core/Vault/Services/SyncServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/SyncServiceTests.swift @@ -142,7 +142,13 @@ class SyncServiceTests: BitwardenTestCase { func test_checkVaultTimeoutPolicy_actionOnly() async throws { client.result = .httpSuccess(testData: .syncWithCiphers) stateService.activeAccount = .fixture() - policyService.fetchTimeoutPolicyValuesResult = .success((.logout, 60)) + policyService.fetchTimeoutPolicyValuesResult = .success( + SessionTimeoutPolicy( + timeoutAction: .logout, + timeoutType: nil, + timeoutValue: SessionTimeoutValue(rawValue: 60), + ), + ) try await subject.fetchSync(forceSync: false) @@ -157,7 +163,13 @@ class SyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.vaultTimeout["1"] = SessionTimeoutValue(rawValue: 120) - policyService.fetchTimeoutPolicyValuesResult = .success((.logout, 60)) + policyService.fetchTimeoutPolicyValuesResult = .success( + SessionTimeoutPolicy( + timeoutAction: .logout, + timeoutType: nil, + timeoutValue: SessionTimeoutValue(rawValue: 60), + ), + ) try await subject.fetchSync(forceSync: false) @@ -165,6 +177,26 @@ class SyncServiceTests: BitwardenTestCase { XCTAssertEqual(stateService.vaultTimeout["1"], SessionTimeoutValue(rawValue: 60)) } + /// `fetchSync()` updates the user's timeout action and ignores the time value. + func test_checkVaultTimeoutPolicy_setActionForOnAppRestartType() async throws { + client.result = .httpSuccess(testData: .syncWithCiphers) + stateService.activeAccount = .fixture() + stateService.vaultTimeout["1"] = SessionTimeoutValue(rawValue: 120) + + policyService.fetchTimeoutPolicyValuesResult = .success( + SessionTimeoutPolicy( + timeoutAction: .logout, + timeoutType: .onAppRestart, + timeoutValue: SessionTimeoutValue(rawValue: 60), + ), + ) + + try await subject.fetchSync(forceSync: false) + + XCTAssertEqual(stateService.timeoutAction["1"], .logout) + XCTAssertEqual(stateService.vaultTimeout["1"], SessionTimeoutValue(rawValue: 120)) + } + /// `fetchSync()` updates the user's timeout action and value - if the timeout value is set to /// never, it is set to the maximum timeout allowed by the policy. func test_checkVaultTimeoutPolicy_valueNever() async throws { @@ -172,7 +204,13 @@ class SyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.vaultTimeout["1"] = .never - policyService.fetchTimeoutPolicyValuesResult = .success((.lock, 15)) + policyService.fetchTimeoutPolicyValuesResult = .success( + SessionTimeoutPolicy( + timeoutAction: .lock, + timeoutType: nil, + timeoutValue: SessionTimeoutValue(rawValue: 15), + ), + ) try await subject.fetchSync(forceSync: false) diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/MockPolicyService.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/MockPolicyService.swift index 7553677d03..c139e31b05 100644 --- a/BitwardenShared/Core/Vault/Services/TestHelpers/MockPolicyService.swift +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/MockPolicyService.swift @@ -1,3 +1,4 @@ +import BitwardenKit import BitwardenSdk @testable import BitwardenShared @@ -20,7 +21,7 @@ class MockPolicyService: PolicyService { var isSendHideEmailDisabledByPolicy = false - var fetchTimeoutPolicyValuesResult: Result<(SessionTimeoutAction?, Int)?, Error> = .success(nil) + var fetchTimeoutPolicyValuesResult: Result = .success(nil) var organizationsApplyingPolicyToUserResult: [PolicyType: [String]] = [:] @@ -55,10 +56,7 @@ class MockPolicyService: PolicyService { isSendHideEmailDisabledByPolicy } - func fetchTimeoutPolicyValues() async throws -> ( - action: SessionTimeoutAction?, - value: Int, - )? { + func fetchTimeoutPolicyValues() async throws -> SessionTimeoutPolicy? { try fetchTimeoutPolicyValuesResult.get() } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift index 7245442348..da6041e203 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityProcessor.swift @@ -118,15 +118,22 @@ final class AccountSecurityProcessor: StateProcessor= 0: minutes + case .custom, .never, .onAppRestart: nil + } + } + + var isCustomPlaceholder: Bool { + if case let .custom(minutes) = self { + minutes < 0 + } else { + false } } } @@ -72,7 +72,12 @@ struct AccountSecurityState: Equatable { var availableTimeoutActions: [SessionTimeoutAction] = SessionTimeoutAction.allCases /// The timeout options to show when the policy for maximum timeout value is in effect. - var availableTimeoutOptions: [SessionTimeoutValue] = SessionTimeoutValue.allCases + var availableTimeoutOptions: [SessionTimeoutValue] { + availableTimeoutOptions( + type: policyTimeoutType, + value: policyTimeoutValue, + ) + } /// The state of the badges in the settings tab. var badgeState: SettingsBadgeState? @@ -89,33 +94,33 @@ struct AccountSecurityState: Equatable { /// Whether the user has enabled the sync with the authenticator app.. var isAuthenticatorSyncEnabled = false + /// Whether the timeout action policy is in effect. + var isPolicyTimeoutActionEnabled = false + /// Whether the timeout policy is in effect. - var isTimeoutPolicyEnabled = false + var isPolicyTimeoutEnabled = false /// Whether the unlock with pin code toggle is on. var isUnlockWithPINCodeOn: Bool = false /// The policy's maximum vault timeout value. - /// - /// When set, all timeout values greater than this are no longer shown. - var policyTimeoutValue: Int = 0 { - didSet { - availableTimeoutOptions = SessionTimeoutValue.allCases - .filter { $0 != .never } - .filter { $0 != .onAppRestart } - .filter { $0.rawValue <= policyTimeoutValue } - } - } + var policyTimeoutValue: Int = 0 /// The policy's timeout action, if set. var policyTimeoutAction: SessionTimeoutAction? + /// The policy's timeout type, if set. + var policyTimeoutType: SessionTimeoutType? + /// Whether the policy to remove Unlock with pin feature is enabled. var removeUnlockWithPinPolicyEnabled: Bool = false /// The action taken when a session timeout occurs. var sessionTimeoutAction: SessionTimeoutAction = .lock + /// The length of time before a session timeout occurs. + var sessionTimeoutType: SessionTimeoutType = .immediately + /// The length of time before a session timeout occurs. var sessionTimeoutValue: SessionTimeoutValue = .immediately @@ -129,12 +134,34 @@ struct AccountSecurityState: Equatable { customTimeoutValueSeconds.timeInHoursMinutes(shouldSpellOut: true) } - /// The custom session timeout value, in seconds, initially set to 60 seconds. - var customTimeoutValueSeconds: Int { - guard case let .custom(customValueInMinutes) = sessionTimeoutValue, customValueInMinutes > 0 else { - return 60 + /// The localized message informing the user of their organization's policy-set session timeout duration. + /// + /// Returns a formatted string describing the default session timeout configured by the organization, + /// expressed in hours and/or minutes depending on the `policyTimeoutValue`. + var customTimeoutMessage: String { + switch (policyTimeoutHours, policyTimeoutMinutes) { + case let (hours, minutes) where hours > 0 && minutes > 0: + Localizations.yourOrganizationHasSetTheDefaultSessionTimeoutToXAndY( + Localizations.xHours( + policyTimeoutHours, + ), + Localizations.xMinutes( + policyTimeoutMinutes, + ), + ) + case let (hours, _) where hours > 0: + Localizations.yourOrganizationHasSetTheDefaultSessionTimeoutToX( + Localizations.xHours( + policyTimeoutHours, + ), + ) + default: + Localizations.yourOrganizationHasSetTheDefaultSessionTimeoutToX( + Localizations.xMinutes( + policyTimeoutMinutes, + ), + ) } - return customValueInMinutes * 60 } /// The string representation of the custom session timeout value. @@ -142,6 +169,14 @@ struct AccountSecurityState: Equatable { customTimeoutValueSeconds.timeInHoursMinutes() } + /// The custom session timeout value, in seconds, initially set to 60 seconds. + var customTimeoutValueSeconds: Int { + guard case let .custom(customValueInMinutes) = sessionTimeoutValue, customValueInMinutes > 0 else { + return 60 + } + return customValueInMinutes * 60 + } + /// Whether the user has a method to unlock the vault (master password, pin set, or biometrics /// enabled). var hasUnlockMethod: Bool { @@ -158,6 +193,11 @@ struct AccountSecurityState: Equatable { !hasUnlockMethod || isTimeoutActionPolicyEnabled } + var isSessionTimeoutPickerDisabled: Bool { + guard case .immediately = policyTimeoutType else { return false } + return true + } + /// Whether the timeout policy specifies a timeout action. var isTimeoutActionPolicyEnabled: Bool { policyTimeoutAction != nil @@ -169,6 +209,36 @@ struct AccountSecurityState: Equatable { return true } + /// The message to display if a timeout action is in effect for the user. + var policyTimeoutActionMessage: String? { + guard isTimeoutActionPolicyEnabled else { return nil } + return Localizations.thisSettingIsManagedByYourOrganization + } + + /// The message to display in the custom timeout field when a policy is in effect. + /// + /// Returns different messages based on the policy type: + /// - `.custom`: Shows the specific timeout duration from the policy + /// - `.immediately`: Indicates the setting is managed by the organization + /// - `.never`/`.onAppRestart`: Shows the policy's timeout type + /// - `nil`: Returns `nil` if no policy is in effect + /// + var policyTimeoutCustomMessage: String? { + guard isPolicyTimeoutEnabled, let policy = policyTimeoutType else { return nil } + switch policyTimeoutType { + case .custom: + return customTimeoutMessage + case .immediately: + return Localizations.thisSettingIsManagedByYourOrganization + case .never: + return Localizations.yourOrganizationHasSetTheDefaultSessionTimeoutToX(policy.timeoutType) + case .onAppRestart: + return Localizations.yourOrganizationHasSetTheDefaultSessionTimeoutToX(policy.timeoutType) + default: + return customTimeoutMessage + } + } + /// The policy's timeout value in hours. var policyTimeoutHours: Int { policyTimeoutValue / 60 @@ -176,19 +246,8 @@ struct AccountSecurityState: Equatable { /// The message to display if a timeout policy is in effect for the user. var policyTimeoutMessage: String? { - guard isTimeoutPolicyEnabled else { return nil } - return if let policyTimeoutAction { - Localizations.vaultTimeoutPolicyWithActionInEffect( - policyTimeoutHours, - policyTimeoutMinutes, - policyTimeoutAction.localizedName, - ) - } else { - Localizations.vaultTimeoutPolicyInEffect( - policyTimeoutHours, - policyTimeoutMinutes, - ) - } + guard !isShowingCustomTimeout else { return nil } + return policyTimeoutCustomMessage } /// The policy's timeout value in minutes. @@ -208,4 +267,38 @@ struct AccountSecurityState: Equatable { var unlockWithPinFeatureAvailable: Bool { !removeUnlockWithPinPolicyEnabled || isUnlockWithPINCodeOn } + + /// Returns the available timeout options based on policy type and value. + /// + /// - Parameters: + /// - type: The policy's timeout type, if set. + /// - value: The policy's maximum vault timeout value. + /// - Returns: Filtered array of available session timeout values. + private func availableTimeoutOptions( + type: SessionTimeoutType?, + value: Int, + ) -> [SessionTimeoutValue] { + SessionTimeoutValue.allCases.filter { option in + switch type { + case .never: + return true + case .onAppRestart: + return option != .never + case .immediately: + return option == .immediately + case .custom: + if option.isCustomPlaceholder { return true } + guard let time = option.minutesValue else { return false } + return time <= value + case nil: + if value > 0 { + if option.isCustomPlaceholder { return true } + guard let time = option.minutesValue else { return false } + return time <= value + } else { + return true + } + } + } + } } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityStateTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityStateTests.swift new file mode 100644 index 0000000000..7f2fa83557 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityStateTests.swift @@ -0,0 +1,233 @@ +import BitwardenKit +import XCTest + +@testable import BitwardenShared + +class AccountSecurityStateTests: BitwardenTestCase { + // MARK: Properties + + var subject: AccountSecurityState! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + subject = AccountSecurityState() + } + + override func tearDown() { + super.tearDown() + subject = nil + } + + // MARK: Tests - availableTimeoutOptions + + /// Tests that `.never` policy type returns all timeout options. + func test_availableTimeoutOptions_neverPolicy_returnsAllCases() { + subject.policyTimeoutType = .never + subject.policyTimeoutValue = 0 + + let options = subject.availableTimeoutOptions + + XCTAssertEqual(options.count, SessionTimeoutValue.allCases.count) + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + XCTAssertTrue(options.contains(.thirtyMinutes)) + XCTAssertTrue(options.contains(.oneHour)) + XCTAssertTrue(options.contains(.fourHours)) + XCTAssertTrue(options.contains(.onAppRestart)) + XCTAssertTrue(options.contains(.never)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + } + + /// Tests that `.onAppRestart` policy type filters out `.never`. + func test_availableTimeoutOptions_onAppRestartPolicy_filtersOutNever() { + subject.policyTimeoutType = .onAppRestart + subject.policyTimeoutValue = 0 + + let options = subject.availableTimeoutOptions + + XCTAssertFalse(options.contains(.never)) + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + XCTAssertTrue(options.contains(.thirtyMinutes)) + XCTAssertTrue(options.contains(.oneHour)) + XCTAssertTrue(options.contains(.fourHours)) + XCTAssertTrue(options.contains(.onAppRestart)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + } + + /// Tests that `.immediately` policy type returns only `.immediately`. + func test_availableTimeoutOptions_immediatelyPolicy_returnsOnlyImmediately() { + subject.policyTimeoutType = .immediately + subject.policyTimeoutValue = 0 + + let options = subject.availableTimeoutOptions + + XCTAssertEqual(options.count, 1) + XCTAssertEqual(options.first, .immediately) + } + + /// Tests that `.custom` policy type filters by maximum value. + func test_availableTimeoutOptions_customPolicy_filtersUpToMaxValue() { + subject.policyTimeoutType = .custom + subject.policyTimeoutValue = 60 + + let options = subject.availableTimeoutOptions + + // Should include options <= 60 minutes + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + XCTAssertTrue(options.contains(.thirtyMinutes)) + XCTAssertTrue(options.contains(.oneHour)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + + // Should not include options > 60 minutes + XCTAssertFalse(options.contains(.fourHours)) + + // Should not include non-minute values + XCTAssertFalse(options.contains(.never)) + XCTAssertFalse(options.contains(.onAppRestart)) + } + + /// Tests that `.custom` policy type with low value filters correctly. + func test_availableTimeoutOptions_customPolicy_lowValue() { + subject.policyTimeoutType = .custom + subject.policyTimeoutValue = 5 + + let options = subject.availableTimeoutOptions + + // Should include options <= 5 minutes + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + + // Should not include options > 5 minutes + XCTAssertFalse(options.contains(.fifteenMinutes)) + XCTAssertFalse(options.contains(.thirtyMinutes)) + XCTAssertFalse(options.contains(.oneHour)) + XCTAssertFalse(options.contains(.fourHours)) + } + + /// Tests that `.custom` policy type with high value includes all minute-based options. + func test_availableTimeoutOptions_customPolicy_highValue() { + subject.policyTimeoutType = .custom + subject.policyTimeoutValue = 1000 + + let options = subject.availableTimeoutOptions + + // Should include all minute-based options + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + XCTAssertTrue(options.contains(.thirtyMinutes)) + XCTAssertTrue(options.contains(.oneHour)) + XCTAssertTrue(options.contains(.fourHours)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + + // Should not include non-minute values + XCTAssertFalse(options.contains(.never)) + XCTAssertFalse(options.contains(.onAppRestart)) + } + + /// Tests that `nil` policy type with value > 0 filters by maximum value. + func test_availableTimeoutOptions_nilPolicy_withPositiveValue() { + subject.policyTimeoutType = nil + subject.policyTimeoutValue = 30 + + let options = subject.availableTimeoutOptions + + // Should include options <= 30 minutes + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + XCTAssertTrue(options.contains(.thirtyMinutes)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + + // Should not include options > 30 minutes + XCTAssertFalse(options.contains(.oneHour)) + XCTAssertFalse(options.contains(.fourHours)) + + // Should not include non-minute values + XCTAssertFalse(options.contains(.never)) + XCTAssertFalse(options.contains(.onAppRestart)) + } + + /// Tests that `nil` policy type with value == 0 returns all options. + func test_availableTimeoutOptions_nilPolicy_withZeroValue() { + subject.policyTimeoutType = nil + subject.policyTimeoutValue = 0 + + let options = subject.availableTimeoutOptions + + XCTAssertEqual(options.count, SessionTimeoutValue.allCases.count) + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + XCTAssertTrue(options.contains(.thirtyMinutes)) + XCTAssertTrue(options.contains(.oneHour)) + XCTAssertTrue(options.contains(.fourHours)) + XCTAssertTrue(options.contains(.onAppRestart)) + XCTAssertTrue(options.contains(.never)) + XCTAssertTrue(options.contains { $0.isCustomPlaceholder }) + } + + /// Tests that `nil` policy type with negative value returns all options. + func test_availableTimeoutOptions_nilPolicy_withNegativeValue() { + subject.policyTimeoutType = nil + subject.policyTimeoutValue = -1 + + let options = subject.availableTimeoutOptions + + // Negative value should be treated as 0 (no restriction) + XCTAssertEqual(options.count, SessionTimeoutValue.allCases.count) + } + + /// Tests boundary condition: exactly matching the policy value. + func test_availableTimeoutOptions_customPolicy_exactMatch() { + subject.policyTimeoutType = .custom + subject.policyTimeoutValue = 15 + + let options = subject.availableTimeoutOptions + + // Should include the exact value + XCTAssertTrue(options.contains(.fifteenMinutes)) + + // Should include smaller values + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + + // Should not include larger values + XCTAssertFalse(options.contains(.thirtyMinutes)) + XCTAssertFalse(options.contains(.oneHour)) + XCTAssertFalse(options.contains(.fourHours)) + } + + /// Tests boundary condition: value between two predefined options. + func test_availableTimeoutOptions_customPolicy_betweenOptions() { + subject.policyTimeoutType = .custom + subject.policyTimeoutValue = 20 + + let options = subject.availableTimeoutOptions + + // Should include values <= 20 + XCTAssertTrue(options.contains(.immediately)) + XCTAssertTrue(options.contains(.oneMinute)) + XCTAssertTrue(options.contains(.fiveMinutes)) + XCTAssertTrue(options.contains(.fifteenMinutes)) + + // Should not include 30 (> 20) + XCTAssertFalse(options.contains(.thirtyMinutes)) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView+SnapshotTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView+SnapshotTests.swift index a6e1d75e84..0ab51f6031 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView+SnapshotTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView+SnapshotTests.swift @@ -136,7 +136,7 @@ class AccountSecurityViewTests: BitwardenTestCase { store: Store( processor: StateProcessor( state: AccountSecurityState( - isTimeoutPolicyEnabled: true, + isPolicyTimeoutEnabled: true, sessionTimeoutValue: .custom(1), ), ), @@ -155,7 +155,7 @@ class AccountSecurityViewTests: BitwardenTestCase { store: Store( processor: StateProcessor( state: AccountSecurityState( - isTimeoutPolicyEnabled: true, + isPolicyTimeoutEnabled: true, policyTimeoutAction: .logout, sessionTimeoutAction: .logout, sessionTimeoutValue: .custom(1), diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView.swift b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView.swift index e283468da9..e1d968970a 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AccountSecurity/AccountSecurityView.swift @@ -146,47 +146,45 @@ struct AccountSecurityView: View { /// The session timeout section. private var sessionTimeoutSection: some View { SectionView(Localizations.sessionTimeout, contentSpacing: 8) { - VStack(spacing: 16) { - if let policyTimeoutMessage = store.state.policyTimeoutMessage { - InfoContainer(policyTimeoutMessage) - } - - ContentBlock(dividerLeadingPadding: 16) { - BitwardenMenuField( - title: Localizations.sessionTimeout, - accessibilityIdentifier: "VaultTimeoutChooser", - options: store.state.availableTimeoutOptions, - selection: store.binding( - get: \.sessionTimeoutValue, - send: AccountSecurityAction.sessionTimeoutValueChanged, - ), - ) - - if store.state.isShowingCustomTimeout { - SettingsPickerField( - title: Localizations.custom, - customTimeoutValue: store.state.customTimeoutString, - pickerValue: store.binding( - get: \.customTimeoutValueSeconds, - send: AccountSecurityAction.customTimeoutValueSecondsChanged, - ), - hasDivider: false, - customTimeoutAccessibilityLabel: store.state.customTimeoutAccessibilityLabel, - ) - } - - BitwardenMenuField( - title: Localizations.sessionTimeoutAction, - accessibilityIdentifier: "VaultTimeoutActionChooser", - options: store.state.availableTimeoutActions, - selection: store.binding( - get: \.sessionTimeoutAction, - send: AccountSecurityAction.sessionTimeoutActionChanged, + ContentBlock(dividerLeadingPadding: 16) { + BitwardenMenuField( + title: Localizations.sessionTimeout, + footer: store.state.policyTimeoutMessage, + accessibilityIdentifier: "VaultTimeoutChooser", + options: store.state.availableTimeoutOptions, + selection: store.binding( + get: \.sessionTimeoutValue, + send: AccountSecurityAction.sessionTimeoutValueChanged, + ), + ) + .disabled(store.state.isSessionTimeoutPickerDisabled) + + if store.state.isShowingCustomTimeout { + SettingsPickerField( + title: "", + footer: store.state.policyTimeoutCustomMessage, + customTimeoutValue: store.state.customTimeoutString, + pickerValue: store.binding( + get: \.customTimeoutValueSeconds, + send: AccountSecurityAction.customTimeoutValueSecondsChanged, ), + customTimeoutAccessibilityLabel: store.state.customTimeoutAccessibilityLabel, ) - .disabled(store.state.isSessionTimeoutActionDisabled) } } + ContentBlock(dividerLeadingPadding: 16) { + BitwardenMenuField( + title: Localizations.sessionTimeoutAction, + footer: store.state.policyTimeoutActionMessage, + accessibilityIdentifier: "VaultTimeoutActionChooser", + options: store.state.availableTimeoutActions, + selection: store.binding( + get: \.sessionTimeoutAction, + send: AccountSecurityAction.sessionTimeoutActionChanged, + ), + ) + .disabled(store.state.isSessionTimeoutActionDisabled) + } } }