diff --git a/BitwardenShared/Core/Platform/Models/Domain/Account.swift b/BitwardenShared/Core/Platform/Models/Domain/Account.swift index 0fe95bc04..300bf1364 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/Account.swift +++ b/BitwardenShared/Core/Platform/Models/Domain/Account.swift @@ -120,6 +120,9 @@ extension Account { /// The account's security stamp. var stamp: String? + /// Whether the account has two-factor enabled. + var twoFactorEnabled: Bool? + /// User decryption options for the account. var userDecryptionOptions: UserDecryptionOptions? diff --git a/BitwardenShared/Core/Platform/Models/Domain/Fixtures/Account+Fixtures.swift b/BitwardenShared/Core/Platform/Models/Domain/Fixtures/Account+Fixtures.swift index 3d67a6e14..9153ca438 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/Fixtures/Account+Fixtures.swift +++ b/BitwardenShared/Core/Platform/Models/Domain/Fixtures/Account+Fixtures.swift @@ -91,6 +91,7 @@ extension Account.AccountProfile { name: String? = nil, orgIdentifier: String? = nil, stamp: String? = "stamp", + twoFactorEnabled: Bool? = nil, userDecryptionOptions: UserDecryptionOptions? = nil, userId: String = "1" ) -> Account.AccountProfile { @@ -107,6 +108,7 @@ extension Account.AccountProfile { name: name, orgIdentifier: orgIdentifier, stamp: stamp, + twoFactorEnabled: twoFactorEnabled, userDecryptionOptions: userDecryptionOptions, userId: userId ) diff --git a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift index a9d20028e..59df4c830 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift @@ -35,6 +35,12 @@ enum FeatureFlag: String, CaseIterable, Codable { /// A feature flag for the create account flow. case nativeCreateAccountFlow = "native-create-account-flow" + /// A feature flag for the new device verification flow. + case newDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss" + + /// A feature flag for the new device verification flow. + case newDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss" + case sshKeyVaultItem = "ssh-key-vault-item" /// A feature flag for the refactor on the SSO details endpoint. @@ -97,6 +103,8 @@ enum FeatureFlag: String, CaseIterable, Codable { .importLoginsFlow, .nativeCarouselFlow, .nativeCreateAccountFlow, + .newDeviceVerificationPermanentDismiss, + .newDeviceVerificationTemporaryDismiss, .testLocalFeatureFlag, .testLocalInitialBoolFlag, .testLocalInitialIntFlag, diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 904224164..7ecda6fe8 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -42,6 +42,12 @@ protocol StateService: AnyObject { /// func doesActiveAccountHavePremium() async throws -> Bool + /// Returns whether the active user account has two-factor authentication turned on. + /// + /// - Returns: Whether the active account has two-factor authentication turned on. + /// + func doesActiveAccountHaveTwoFactor() async throws -> Bool + /// Gets the account for an id. /// /// - Parameter userId: The id for an account. If nil, the active account will be returned. @@ -307,6 +313,14 @@ protocol StateService: AnyObject { /// func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction + /// Gets the display state of the no-two-factor notice for a user ID. + /// + /// - Parameters: + /// - userId: The user ID for the account; defaults to current active user if `nil`. + /// - Returns: The display state. + /// + func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState + /// Get the two-factor token (non-nil if the user selected the "remember me" option). /// /// - Parameter email: The user's email address. @@ -642,6 +656,14 @@ protocol StateService: AnyObject { /// func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws + /// Sets the user's no-two-factor notice display state for a userID. + /// + /// - Parameters: + /// - state: The display state to set. + /// - userId: The user ID associated with the state + /// + func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws + /// Sets the user's two-factor token. /// /// - Parameters: @@ -937,6 +959,14 @@ extension StateService { try await getTimeoutAction(userId: nil) } + /// Gets the display state of the no-two-factor notice for the current user. + /// + /// - Returns: The display state. + /// + func getTwoFactorNoticeDisplayState() async throws -> TwoFactorNoticeDisplayState { + try await getTwoFactorNoticeDisplayState(userId: nil) + } + /// Sets the number of unsuccessful attempts to unlock the vault for the active account. /// /// - Returns: The number of unsuccessful unlock attempts for the active account. @@ -1155,6 +1185,15 @@ extension StateService { try await setSyncToAuthenticator(syncToAuthenticator, userId: nil) } + /// Sets the display state for the no-two-factor notice + /// + /// - Parameters: + /// - state: The state to set. + /// + func setTwoFactorNoticeDisplayState(state: TwoFactorNoticeDisplayState) async throws { + try await setTwoFactorNoticeDisplayState(state, userId: nil) + } + /// Sets the session timeout action. /// /// - Parameter action: The action to take when the user's session times out. @@ -1353,6 +1392,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le return !organizations.isEmpty } + func doesActiveAccountHaveTwoFactor() async throws -> Bool { + let account = try await getActiveAccount() + return account.profile.twoFactorEnabled ?? false + } + func getAccount(userId: String?) throws -> Account { guard let accounts = appSettingsStore.state?.accounts else { throw StateServiceError.noAccounts @@ -1545,6 +1589,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le return timeoutAction } + func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState { + let userId = try userId ?? getActiveAccountUserId() + return appSettingsStore.twoFactorNoticeDisplayState(userId: userId) + } + func getTwoFactorToken(email: String) async -> String? { appSettingsStore.twoFactorToken(email: email) } @@ -1835,6 +1884,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le appSettingsStore.setTimeoutAction(key: action, userId: userId) } + func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() + appSettingsStore.setTwoFactorNoticeDisplayState(state, userId: userId) + } + func setTwoFactorToken(_ token: String?, email: String) async { appSettingsStore.setTwoFactorToken(token, email: email) } @@ -1881,6 +1935,7 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le profile.emailVerified = response.emailVerified profile.name = response.name profile.stamp = response.securityStamp + profile.twoFactorEnabled = response.twoFactorEnabled state.accounts[userId]?.profile = profile } diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 37e9caa49..38f4c6eee 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -247,6 +247,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertFalse(hasPremium) } + /// `doesActiveAccountHaveTwoFactor()` returns whether the active account + /// has two-factor enabled + func test_doesActiveAccountHaveTwoFactor() async throws { + await subject.addAccount(.fixture(profile: .fixture(twoFactorEnabled: true))) + let hasTwoFactor = try await subject.doesActiveAccountHaveTwoFactor() + XCTAssertTrue(hasTwoFactor) + } + /// `getAccountEncryptionKeys(_:)` returns the encryption keys for the user account. func test_getAccountEncryptionKeys() async throws { appSettingsStore.encryptedPrivateKeys["1"] = "1:PRIVATE_KEY" @@ -897,6 +905,27 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertEqual(action, .logout) } + /// `getTwoFactorNoticeDisplayState(userId:)` gets the display state of the two-factor notice for the user. + func test_getTwoFactorNoticeDisplayState() async throws { + appSettingsStore.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "person@example.com") + + let value = try await subject.getTwoFactorNoticeDisplayState(userId: "person@example.com") + XCTAssertEqual(value, .canAccessEmail) + } + + /// `getTwoFactorNoticeDisplayState()` gets the display state of the two-factor notice for the current user + /// and throws an error if there is no current user. + func test_getTwoFactorNoticeDisplayState_noId() async throws { + appSettingsStore.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "1") + + do { + try await _ = subject.getTwoFactorNoticeDisplayState() + XCTFail("subject.getTwoFactorNoticeDisplayState() should throw an error if there is no active account") + } catch { + XCTAssertEqual(error as? StateServiceError, StateServiceError.noActiveAccount) + } + } + /// `getTwoFactorToken(email:)` gets the two-factor code associated with the email. func test_getTwoFactorToken() async { appSettingsStore.setTwoFactorToken("yay_you_win!", email: "winner@email.com") @@ -1976,6 +2005,12 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body } } + /// `setTwoFactorNoticeDisplayState(_:userId:)` sets the display state of the two-factor notice for the user. + func test_setTwoFactorNoticeDisplayState() async throws { + try await subject.setTwoFactorNoticeDisplayState(.hasNotSeen, userId: "person1@example.com") + XCTAssertEqual(appSettingsStore.twoFactorNoticeDisplayState(userId: "person1@example.com"), .hasNotSeen) + } + /// `setTwoFactorToken(_:email:)` sets the two-factor code for the email. func test_setTwoFactorToken() async { await subject.setTwoFactorToken("yay_you_win!", email: "winner@email.com") @@ -2148,6 +2183,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body hasPremiumPersonally: false, name: "User", stamp: "stamp", + twoFactorEnabled: false, userId: "1" ) ) @@ -2160,7 +2196,8 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body emailVerified: true, name: "Other", premium: true, - securityStamp: "new stamp" + securityStamp: "new stamp", + twoFactorEnabled: true ), userId: "1" ) @@ -2176,6 +2213,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body hasPremiumPersonally: true, name: "Other", stamp: "new stamp", + twoFactorEnabled: true, userId: "1" ) ) diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index 566a11c9b..b6bca71c7 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -452,6 +452,14 @@ protocol AppSettingsStore: AnyObject { /// func setTimeoutAction(key: SessionTimeoutAction, userId: String) + /// Sets the display state for the two-factor notice. + /// + /// - Parameters: + /// - state: The display state. + /// - userId: The userID associated with the state. + /// + func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) + /// Sets the two-factor token. /// /// - Parameters: @@ -463,7 +471,7 @@ protocol AppSettingsStore: AnyObject { /// Sets the number of unsuccessful attempts to unlock the vault for a user ID. /// /// - Parameters: - /// - attempts: The number of unsuccessful unlock attempts.. + /// - attempts: The number of unsuccessful unlock attempts. /// - userId: The user ID associated with the unsuccessful unlock attempts. /// func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String) @@ -513,6 +521,14 @@ protocol AppSettingsStore: AnyObject { /// func timeoutAction(userId: String) -> Int? + /// Get the display state of the no-two-factor notice for a user ID. + /// + /// - Parameters: + /// - userId: The user ID associated with the state. + /// - Returns: The state for the user ID. + /// + func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState + /// Get the two-factor token associated with a user's email. /// /// - Parameter email: The user's email. @@ -713,6 +729,7 @@ extension DefaultAppSettingsStore: AppSettingsStore { case shouldTrustDevice(userId: String) case syncToAuthenticator(userId: String) case state + case twoFactorNoticeDisplayState(userId: String) case twoFactorToken(email: String) case unsuccessfulUnlockAttempts(userId: String) case usernameGenerationOptions(userId: String) @@ -806,6 +823,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "state" case let .syncToAuthenticator(userId): key = "shouldSyncToAuthenticator_\(userId)" + case let .twoFactorNoticeDisplayState(userId): + key = "twoFactorNoticeDisplayState_\(userId)" case let .twoFactorToken(email): key = "twoFactorToken_\(email)" case let .unsuccessfulUnlockAttempts(userId): @@ -1115,6 +1134,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { store(key, for: .vaultTimeoutAction(userId: userId)) } + func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) { + store(state, for: .twoFactorNoticeDisplayState(userId: userId)) + } + func setTwoFactorToken(_ token: String?, email: String) { store(token, for: .twoFactorToken(email: email)) } @@ -1139,6 +1162,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { fetch(for: .vaultTimeoutAction(userId: userId)) } + func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState { + fetch(for: .twoFactorNoticeDisplayState(userId: userId)) ?? .hasNotSeen + } + func twoFactorToken(email: String) -> String? { fetch(for: .twoFactorToken(email: email)) } diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index 33f2bd95c..6c4b08a40 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -856,6 +856,21 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_2")) } + /// `twoFactorNoticeDisplayState(userId:)` returns `.hasNotSeen` if there isn't a previously stored value. + func test_twoFactorNoticeDisplayState_isInitiallyNotSeen() { + XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "anyone@example.com"), .hasNotSeen) + } + + /// `twoFactorToken(email:)` can be used to get and set the persisted value in user defaults. + func test_twoFactorNoticeDisplayState_withValue() { + let date = Date() + subject.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "person1@example.com") + subject.setTwoFactorNoticeDisplayState(.seen(date), userId: "person2@example.com") + + XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "person1@example.com"), .canAccessEmail) + XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "person2@example.com"), .seen(date)) + } + /// `twoFactorToken(email:)` returns `nil` if there isn't a previously stored value. func test_twoFactorToken_isInitiallyNil() { XCTAssertNil(subject.twoFactorToken(email: "anything@email.com")) diff --git a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index 2c468ab9f..34a5585e9 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -48,6 +48,7 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo var shouldTrustDevice = [String: Bool?]() var syncToAuthenticatorByUserId = [String: Bool]() var timeoutAction = [String: Int]() + var twoFactorNoticeDisplayState = [String: TwoFactorNoticeDisplayState]() var twoFactorTokens = [String: String]() var usesKeyConnector = [String: Bool]() var vaultTimeout = [String: Int]() @@ -279,6 +280,14 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo timeoutAction[userId] = key.rawValue } + func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) { + twoFactorNoticeDisplayState[userId] = state + } + + func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState { + twoFactorNoticeDisplayState[userId] ?? .hasNotSeen + } + func setTwoFactorToken(_ token: String?, email: String) { twoFactorTokens[email] = token } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift index 64aeaf70f..eb01018f9 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -33,6 +33,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt var disableAutoTotpCopyByUserId = [String: Bool]() var doesActiveAccountHavePremiumCalled = false var doesActiveAccountHavePremiumResult: Result = .success(true) + var doesActiveAccountHaveTwoFactorResult: Result = .success(false) var encryptedPinByUserId = [String: String]() var environmentUrls = [String: EnvironmentUrlData]() var environmentUrlsError: Error? @@ -74,11 +75,14 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt var setAppRehydrationStateError: Error? var setBiometricAuthenticationEnabledResult: Result = .success(()) var setBiometricIntegrityStateError: Error? + var setTwoFactorNoticeDisplayStateError: Error? var settingsBadgeSubject = CurrentValueSubject(.fixture()) var shouldTrustDevice = [String: Bool?]() var syncToAuthenticatorByUserId = [String: Bool]() var syncToAuthenticatorResult: Result = .success(()) var syncToAuthenticatorSubject = CurrentValueSubject<(String?, Bool), Never>((nil, false)) + var twoFactorNoticeDisplayState = [String: TwoFactorNoticeDisplayState]() + var twoFactorNoticeDisplayStateError: Error? var twoFactorTokens = [String: String]() var unsuccessfulUnlockAttempts = [String: Int]() var updateProfileResponse: ProfileResponseModel? @@ -124,6 +128,10 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt return try doesActiveAccountHavePremiumResult.get() } + func doesActiveAccountHaveTwoFactor() async throws -> Bool { + try doesActiveAccountHaveTwoFactorResult.get() + } + func getAccountEncryptionKeys(userId: String?) async throws -> AccountEncryptionKeys { if let error = getAccountEncryptionKeysError { throw error @@ -324,6 +332,14 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt return timeoutAction[userId] ?? .lock } + func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState { + if let error = twoFactorNoticeDisplayStateError { + throw error + } + let userId = try unwrapUserId(userId) + return twoFactorNoticeDisplayState[userId] ?? .hasNotSeen + } + func getTwoFactorToken(email: String) async -> String? { twoFactorTokens[email] } @@ -601,6 +617,14 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt accountTokens = Account.AccountTokens(accessToken: accessToken, refreshToken: refreshToken) } + func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws { + if let error = setTwoFactorNoticeDisplayStateError { + throw error + } + let userId = try unwrapUserId(userId) + twoFactorNoticeDisplayState[userId] = state + } + func setTwoFactorToken(_ token: String?, email: String) async { twoFactorTokens[email] = token } diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/Alert+TwoFactorNotice.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/Alert+TwoFactorNotice.swift new file mode 100644 index 000000000..aaf330627 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/Alert+TwoFactorNotice.swift @@ -0,0 +1,45 @@ +// MARK: - Alert + TwoFactorNotice + +extension Alert { + // MARK: Methods + + /// An alert that asks if the user wants to change their email + /// in a browser. + /// + /// - Parameters: + /// - action: The action taken if they select continue. + /// - Returns: An alert that asks if the user wants to navigate to the change email page + /// + static func changeEmailAlert(action: @escaping () -> Void) -> Alert { + Alert( + title: Localizations.changeAccountEmail, + message: Localizations.changeEmailConfirmation, + alertActions: [ + AlertAction(title: Localizations.cancel, style: .cancel), + AlertAction(title: Localizations.continue, style: .default) { _ in + action() + }, + ] + ) + } + + /// An alert that asks if the user wants to turn two-factor login on + /// in a browser. + /// + /// - Parameters: + /// - action: The action taken if they select continue. + /// - Returns: An alert that asks if the user wants to navigate to the two-factor login setup page + /// + static func turnOnTwoFactorLoginAlert(action: @escaping () -> Void) -> Alert { + Alert( + title: Localizations.turnOnTwoStepLogin, + message: Localizations.turnOnTwoStepLoginConfirmation, + alertActions: [ + AlertAction(title: Localizations.cancel, style: .cancel), + AlertAction(title: Localizations.continue, style: .default) { _ in + action() + }, + ] + ) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/Alert+TwoFactorNoticeTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/Alert+TwoFactorNoticeTests.swift new file mode 100644 index 000000000..2ba0e458e --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/Alert+TwoFactorNoticeTests.swift @@ -0,0 +1,49 @@ +// swiftlint:disable:this file_name + +import XCTest + +@testable import BitwardenShared + +class AlertTwoFactorNoticeTests: BitwardenTestCase { + // MARK: Tests + + /// Tests the `changeEmailAlert` alert contains the correct properties. + func test_changeEmailAlert() { + let subject = Alert.changeEmailAlert {} + + XCTAssertEqual(subject.title, Localizations.changeAccountEmail) + XCTAssertEqual(subject.message, Localizations.changeEmailConfirmation) + XCTAssertEqual(subject.preferredStyle, .alert) + XCTAssertEqual(subject.alertActions.count, 2) + + let action1 = subject.alertActions[0] + XCTAssertEqual(action1.title, Localizations.cancel) + XCTAssertEqual(action1.style, .cancel) + XCTAssertNil(action1.handler) + + let action2 = subject.alertActions[1] + XCTAssertEqual(action2.title, Localizations.continue) + XCTAssertEqual(action2.style, .default) + XCTAssertNotNil(action2.handler) + } + + /// Tests the `turnOnTwoFactorLoginAlert` alert contains the correct properties. + func test_turnOnTwoFactorLoginAlert() { + let subject = Alert.turnOnTwoFactorLoginAlert {} + + XCTAssertEqual(subject.title, Localizations.turnOnTwoStepLogin) + XCTAssertEqual(subject.message, Localizations.turnOnTwoStepLoginConfirmation) + XCTAssertEqual(subject.preferredStyle, .alert) + XCTAssertEqual(subject.alertActions.count, 2) + + let action1 = subject.alertActions[0] + XCTAssertEqual(action1.title, Localizations.cancel) + XCTAssertEqual(action1.style, .cancel) + XCTAssertNil(action1.handler) + + let action2 = subject.alertActions[1] + XCTAssertEqual(action2.title, Localizations.continue) + XCTAssertEqual(action2.style, .default) + XCTAssertNotNil(action2.handler) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessAction.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessAction.swift new file mode 100644 index 000000000..86b039407 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessAction.swift @@ -0,0 +1,8 @@ +// MARK: - EmailAccessAction + +/// Actions that can be processed by a `EmailAccessProcessor`. +/// +enum EmailAccessAction: Equatable, Sendable { + /// The user changed the toggle for being able to access email. + case canAccessEmailChanged(Bool) +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessEffect.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessEffect.swift new file mode 100644 index 000000000..280e1794b --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessEffect.swift @@ -0,0 +1,8 @@ +// MARK: - EmailAccessEffect + +/// Effects that can be processed by a `EmailAccessProcessor`. +/// +enum EmailAccessEffect: Equatable, Sendable { + /// The user tapped the continue button. + case continueTapped +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessProcessor.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessProcessor.swift new file mode 100644 index 000000000..9dd744d95 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessProcessor.swift @@ -0,0 +1,76 @@ +import Combine +import SwiftUI + +// MARK: - EmailAccessProcessor + +/// The processor used to manage state and handle actions for the new device notice screen. +/// +class EmailAccessProcessor: StateProcessor { + // MARK: Types + + typealias Services = HasErrorReporter + & HasStateService + + // MARK: Private Properties + + /// The coordinator that handles navigation. + private let coordinator: AnyCoordinator + + /// The services required by this processor. + private let services: Services + + // MARK: Initialization + + /// Creates a new `EmailAccessProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - services: The services required by this processor. + /// - state: The initial state of the processor. + /// + init( + coordinator: AnyCoordinator, + services: Services, + state: EmailAccessState + ) { + self.coordinator = coordinator + self.services = services + + super.init(state: state) + } + + // MARK: Methods + + override func perform(_ effect: EmailAccessEffect) async { + switch effect { + case .continueTapped: + await handleContinue() + } + } + + override func receive(_ action: EmailAccessAction) { + switch action { + case let .canAccessEmailChanged(canAccess): + state.canAccessEmail = canAccess + } + } + + // MARK: Private Methods + + /// Checks the state of the UI when the user taps the continue button + /// and routes accordingly. + private func handleContinue() async { + do { + if state.canAccessEmail { + let displayState: TwoFactorNoticeDisplayState + displayState = state.allowDelay ? .canAccessEmail : .canAccessEmailPermanent + try await services.stateService.setTwoFactorNoticeDisplayState(state: displayState) + coordinator.navigate(to: .dismiss) + } else { + coordinator.navigate(to: .setUpTwoFactor(allowDelay: state.allowDelay)) + } + } catch { + services.errorReporter.log(error: error) + } + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessProcessorTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessProcessorTests.swift new file mode 100644 index 000000000..f3144a62e --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessProcessorTests.swift @@ -0,0 +1,114 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - EmailAccessProcessorTests + +class EmailAccessProcessorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var errorReporter: MockErrorReporter! + var stateService: MockStateService! + var subject: EmailAccessProcessor! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + coordinator = MockCoordinator() + errorReporter = MockErrorReporter() + stateService = MockStateService() + + let services = ServiceContainer.withMocks( + errorReporter: errorReporter, + stateService: stateService + ) + + subject = EmailAccessProcessor( + coordinator: coordinator.asAnyCoordinator(), + services: services, + state: EmailAccessState(allowDelay: true) + ) + } + + override func tearDown() { + super.tearDown() + + coordinator = nil + errorReporter = nil + stateService = nil + subject = nil + } + + // MARK: Tests + + /// `.perform(_:)` with `.continueTapped` navigates to set up two factor + /// when the user does not indicate they can access their email + @MainActor + func test_perform_continueTapped_canAccessEmail_false() async { + subject.state.allowDelay = false + subject.state.canAccessEmail = false + await subject.perform(.continueTapped) + XCTAssertEqual(coordinator.routes.last, .setUpTwoFactor(allowDelay: false)) + } + + /// `.perform(_:)` with `.continueTapped` updates the state and navigates to dismiss + /// when the user indicates they can access their email + /// and delay is not allowed + @MainActor + func test_perform_continueTapped_canAccessEmail_true_allowDelay_false() async { + let account = Account.fixture() + stateService.activeAccount = account + subject.state.allowDelay = false + subject.state.canAccessEmail = true + await subject.perform(.continueTapped) + XCTAssertEqual( + stateService.twoFactorNoticeDisplayState["1"], + .canAccessEmailPermanent + ) + XCTAssertEqual(coordinator.routes.last, .dismiss) + } + + /// `.perform(_:)` with `.continueTapped` updates the state and navigates to dismiss + /// when the user indicates they can access their email + /// and delay is allowed + @MainActor + func test_perform_continueTapped_canAccessEmail_true_allowDelay_true() async { + let account = Account.fixture() + stateService.activeAccount = account + subject.state.allowDelay = true + subject.state.canAccessEmail = true + await subject.perform(.continueTapped) + XCTAssertEqual( + stateService.twoFactorNoticeDisplayState["1"], + .canAccessEmail + ) + XCTAssertEqual(coordinator.routes.last, .dismiss) + } + + /// `.perform(_:)` with `.continueTapped` handles errors + @MainActor + func test_perform_continueTapped_error() async { + let account = Account.fixture() + stateService.activeAccount = account + stateService.setTwoFactorNoticeDisplayStateError = BitwardenTestError.example + subject.state.allowDelay = false + subject.state.canAccessEmail = true + await subject.perform(.continueTapped) + XCTAssertEqual( + errorReporter.errors.last as? BitwardenTestError, + BitwardenTestError.example + ) + } + + /// `.receive(_:)` with `.canAccessEmailChanged` updates the state + @MainActor + func test_receive_canAccessEmailChanged() { + subject.receive(.canAccessEmailChanged(true)) + XCTAssertTrue(subject.state.canAccessEmail) + subject.receive(.canAccessEmailChanged(false)) + XCTAssertFalse(subject.state.canAccessEmail) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessState.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessState.swift new file mode 100644 index 000000000..acc690a8c --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessState.swift @@ -0,0 +1,15 @@ +import SwiftUI + +// MARK: - EmailAccessState + +/// An object that defines the current state of a `EmailAccessView`. +/// +struct EmailAccessState: Equatable, Sendable { + // MARK: Properties + + /// Whether or not the user can delay setting up two-factor authentication. + var allowDelay: Bool + + /// User-provided value for whether or not they can access their given email address. + var canAccessEmail: Bool = false +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessView.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessView.swift new file mode 100644 index 000000000..d44ce6988 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import SwiftUIIntrospect + +// MARK: - EmailAccessView + +/// A view that alerts the user to the new policy of sending emails to confirm new devices. +/// +struct EmailAccessView: View { + // MARK: Properties + + /// An environment variable for getting the vertical size class of the view. + @Environment(\.verticalSizeClass) var verticalSizeClass + + /// The `Store` for this view. + @ObservedObject public var store: Store + + var body: some View { + VStack(spacing: 12) { + DynamicImageTextStackView(minHeight: 0) { + Asset.Images.Illustrations.businessWarning.swiftUIImage + .resizable() + .frame( + width: verticalSizeClass == .regular ? 152 : 124, + height: verticalSizeClass == .regular ? 152 : 124 + ) + .accessibilityHidden(true) + } textContent: { + VStack(spacing: 16) { + Text(Localizations.importantNotice) + .styleGuide(.title, weight: .bold) + + Text(Localizations.bitwardenWillSendACodeToYourAccountEmail) + .styleGuide(.title3) + } + .padding(.horizontal, 12) + } + + toggleCard + .padding(.horizontal, 12) + + VStack(spacing: 12) { + AsyncButton(Localizations.continue) { + await store.perform(.continueTapped) + } + .buttonStyle(.primary()) + } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Asset.Colors.backgroundPrimary.swiftUIColor.ignoresSafeArea()) + .foregroundStyle(Asset.Colors.textPrimary.swiftUIColor) + .multilineTextAlignment(.center) + .scrollView() + } + + private var toggleCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedStringKey(Localizations.doYouHaveReliableAccessToYourEmail("person\u{2060}@example.com"))) + .styleGuide(.body) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + .accessibilityHidden(true) + + Divider() + + Toggle(Localizations.yesICanReliablyAccessMyEmail, isOn: store.binding( + get: \.canAccessEmail, + send: EmailAccessAction.canAccessEmailChanged + )) + .toggleStyle(.bitwarden) + .accessibilityIdentifier("ItemFavoriteToggle") + } + .multilineTextAlignment(.leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Asset.Colors.backgroundSecondary.swiftUIColor) + .cornerRadius(10) + } +} + +// MARK: - EmailAccessView Previews + +#if DEBUG +#Preview("Email Access") { + NavigationView { + EmailAccessView( + store: Store( + processor: StateProcessor( + state: EmailAccessState( + allowDelay: true + ) + ) + ) + ) + } +} +#endif diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessViewTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessViewTests.swift new file mode 100644 index 000000000..7e504a710 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/EmailAccessViewTests.swift @@ -0,0 +1,55 @@ +import SnapshotTesting +import XCTest + +@testable import BitwardenShared + +// MARK: - EmailAccessViewTests + +class EmailAccessViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: EmailAccessView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + processor = MockProcessor(state: EmailAccessState(allowDelay: true)) + let store = Store(processor: processor) + + subject = EmailAccessView(store: store) + } + + override func tearDown() { + super.tearDown() + + processor = nil + subject = nil + } + + // MARK: Tests + + /// Tapping the continue button sends the `.continueTapped` effect + @MainActor + func test_continueButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.continue) + try button.tap() + + waitFor(!processor.effects.isEmpty) + + XCTAssertEqual(processor.effects.last, .continueTapped) + } + + // MARK: Previews + + /// The email access view renders correctly + @MainActor + func test_snapshot_emailAccessView() { + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5, .defaultLandscape] + ) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.1.png b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.1.png new file mode 100644 index 000000000..be73a2a70 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.1.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.2.png b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.2.png new file mode 100644 index 000000000..bcda203f2 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.2.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.3.png b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.3.png new file mode 100644 index 000000000..87c9f5b00 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.3.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.4.png b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.4.png new file mode 100644 index 000000000..8de1aa438 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/EmailAccess/__Snapshots__/EmailAccessViewTests/test_snapshot_emailAccessView.4.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorAction.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorAction.swift new file mode 100644 index 000000000..01e0f95d0 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorAction.swift @@ -0,0 +1,14 @@ +// MARK: - SetUpTwoFactorAction + +/// Actions that can be processed by a `SetUpTwoFactorProcessor`. +/// +enum SetUpTwoFactorAction: Equatable, Sendable { + /// The url has been opened so clear the value in the state. + case clearURL + + /// The user tapped the button to turn on two-factor authentication. + case turnOnTwoFactorTapped + + /// The user tapped the button to change email. + case changeAccountEmailTapped +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorEffect.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorEffect.swift new file mode 100644 index 000000000..f655bde2e --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorEffect.swift @@ -0,0 +1,8 @@ +// MARK: - SetUpTwoFactorEffect + +/// Effects that can be processed by a `SetUpTwoFactorProcessor`. +/// +enum SetUpTwoFactorEffect: Equatable, Sendable { + /// The user tapped the "remind me later" button. + case remindMeLaterTapped +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorProcessor.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorProcessor.swift new file mode 100644 index 000000000..dace053d9 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorProcessor.swift @@ -0,0 +1,80 @@ +import Combine +import SwiftUI + +// MARK: - SetUpTwoFactorProcessor + +/// The processor used to manage state and handle actions for the new device notice screen. +/// +class SetUpTwoFactorProcessor: StateProcessor { + // MARK: Types + + typealias Services = HasEnvironmentService + & HasErrorReporter + & HasStateService + & HasTimeProvider + + // MARK: Private Properties + + /// The coordinator that handles navigation. + private let coordinator: AnyCoordinator + + /// The services required by this processor. + private let services: Services + + // MARK: Initialization + + /// Creates a new `SetUpTwoFactorProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - services: The services required by this processor. + /// - state: The initial state of the processor. + /// + init( + coordinator: AnyCoordinator, + services: Services, + state: SetUpTwoFactorState + ) { + self.coordinator = coordinator + self.services = services + + super.init(state: state) + } + + // MARK: Methods + + override func perform(_ effect: SetUpTwoFactorEffect) async { + switch effect { + case .remindMeLaterTapped: + await handleDismiss() + } + } + + override func receive(_ action: SetUpTwoFactorAction) { + switch action { + case .changeAccountEmailTapped: + coordinator.showAlert(.changeEmailAlert { + self.state.url = self.services.environmentService.changeEmailURL + }) + case .clearURL: + state.url = nil + case .turnOnTwoFactorTapped: + coordinator.showAlert(.turnOnTwoFactorLoginAlert { + self.state.url = self.services.environmentService.setUpTwoFactorURL + }) + } + } + + // MARK: Private Methods + + /// Saves the current time to disk when the user dismisses the notice. + private func handleDismiss() async { + do { + let currentTime = services.timeProvider.presentTime + try await services.stateService.setTwoFactorNoticeDisplayState(state: .seen(currentTime)) + coordinator.navigate(to: .dismiss) + } catch { + services.errorReporter.log(error: error) + } + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorProcessorTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorProcessorTests.swift new file mode 100644 index 000000000..714be1d51 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorProcessorTests.swift @@ -0,0 +1,113 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - SetUpTwoFactorProcessorTests + +class SetUpTwoFactorProcessorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var environmentService: MockEnvironmentService! + var errorReporter: MockErrorReporter! + var stateService: MockStateService! + var subject: SetUpTwoFactorProcessor! + var timeProvider: MockTimeProvider! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + coordinator = MockCoordinator() + environmentService = MockEnvironmentService() + errorReporter = MockErrorReporter() + stateService = MockStateService() + timeProvider = MockTimeProvider(.mockTime(Date())) + + let services = ServiceContainer.withMocks( + environmentService: environmentService, + errorReporter: errorReporter, + stateService: stateService, + timeProvider: timeProvider + ) + + subject = SetUpTwoFactorProcessor( + coordinator: coordinator.asAnyCoordinator(), + services: services, + state: SetUpTwoFactorState(allowDelay: true) + ) + } + + override func tearDown() { + super.tearDown() + + coordinator = nil + environmentService = nil + errorReporter = nil + stateService = nil + subject = nil + timeProvider = nil + } + + // MARK: Tests + + /// `.perform(_:)` with `.remindMeLaterTapped` saves the current time to disk + /// then dismisses + @MainActor + func test_perform_remindMeLaterTapped() async { + let account = Account.fixture() + stateService.activeAccount = account + await subject.perform(.remindMeLaterTapped) + XCTAssertEqual( + stateService.twoFactorNoticeDisplayState["1"], + .seen(timeProvider.presentTime) + ) + XCTAssertEqual(coordinator.routes.last, .dismiss) + } + + /// `.perform(_:)` with `.remindMeLaterTapped` handles errors + @MainActor + func test_perform_remindMeLaterTapped_error() async { + let account = Account.fixture() + stateService.activeAccount = account + stateService.setTwoFactorNoticeDisplayStateError = BitwardenTestError.example + await subject.perform(.remindMeLaterTapped) + XCTAssertEqual( + errorReporter.errors.last as? BitwardenTestError, + BitwardenTestError.example + ) + } + + /// `receive(_:)` with `.changeAccountEmail` shows an alert; + /// and when continue is tapped, the user is sent to the set up two factor site. + @MainActor + func test_receive_changeAccountEmailTapped() async throws { + let url = URL("https://www.example.com")! + environmentService.changeEmailURL = url + subject.receive(.changeAccountEmailTapped) + let alert = try XCTUnwrap(coordinator.alertShown.last) + try await alert.tapAction(title: Localizations.continue) + XCTAssertEqual(subject.state.url, url) + } + + /// `receive(_:)` with `.clearURL` clears the URL in the state. + @MainActor + func test_receive_clearURL() { + subject.state.url = .example + subject.receive(.clearURL) + XCTAssertNil(subject.state.url) + } + + /// `receive(_:)` with `.turnOnTwoFactorTapped` shows an alert; + /// and when continue is tapped, the user is sent to the set up two factor site. + @MainActor + func test_receive_turnOnTwoFactorTapped() async throws { + let url = URL("https://www.example.com")! + environmentService.setUpTwoFactorURL = url + subject.receive(.turnOnTwoFactorTapped) + let alert = try XCTUnwrap(coordinator.alertShown.last) + try await alert.tapAction(title: Localizations.continue) + XCTAssertEqual(subject.state.url, url) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorState.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorState.swift new file mode 100644 index 000000000..fffce0752 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorState.swift @@ -0,0 +1,13 @@ +import Foundation + +// MARK: - SetUpTwoFactorState + +/// An object that defines the current state of a `SetUpTwoFactorView`. +/// +struct SetUpTwoFactorState: Equatable, Sendable { + /// Whether or not the user can delay setting up two-factor authentication. + var allowDelay: Bool + + /// The url to open in the device's web browser. + var url: URL? +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorView.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorView.swift new file mode 100644 index 000000000..9ff6f5c81 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +// MARK: - SetUpTwoFactorView + +/// A view that alerts the user to the new policy of sending emails to confirm new devices. +/// +struct SetUpTwoFactorView: View { + // MARK: Properties + + /// An object used to open urls from this view. + @Environment(\.openURL) private var openURL + + /// An environment variable for getting the vertical size class of the view. + @Environment(\.verticalSizeClass) var verticalSizeClass + + /// The `Store` for this view. + @ObservedObject public var store: Store + + var body: some View { + VStack(spacing: 12) { + DynamicImageTextStackView(minHeight: 0) { + Asset.Images.Illustrations.userLock.swiftUIImage + .resizable() + .frame( + width: verticalSizeClass == .regular ? 152 : 124, + height: verticalSizeClass == .regular ? 152 : 124 + ) + .accessibilityHidden(true) + } textContent: { + VStack(spacing: 16) { + Text(Localizations.setUpTwoStepLogin) + .styleGuide(.title, weight: .bold) + + Text(Localizations.youCanSetUpTwoStepLoginAsAnAlternative) + .styleGuide(.title3) + } + .padding(.horizontal, 12) + } + + Button { + store.send(.turnOnTwoFactorTapped) + } label: { + Label { + Text(Localizations.turnOnTwoStepLogin) + } icon: { + Asset.Images.externalLink24.swiftUIImage + } + } + .buttonStyle(.primary()) + + Button { + store.send(.changeAccountEmailTapped) + } label: { + Label { + Text(Localizations.changeAccountEmail) + } icon: { + Asset.Images.externalLink24.swiftUIImage + } + } + .buttonStyle(.secondary()) + + if store.state.allowDelay { + AsyncButton(Localizations.remindMeLater) { + await store.perform(.remindMeLaterTapped) + } + .buttonStyle(.secondary()) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Asset.Colors.backgroundPrimary.swiftUIColor.ignoresSafeArea()) + .foregroundStyle(Asset.Colors.textPrimary.swiftUIColor) + .multilineTextAlignment(.center) + .scrollView() + .onChange(of: store.state.url) { newValue in + guard let url = newValue else { return } + openURL(url) + store.send(.clearURL) + } + } +} + +// MARK: - SetUpTwoFactorView Previews + +#if DEBUG +#Preview("Allowing Delay") { + NavigationView { + SetUpTwoFactorView( + store: Store( + processor: StateProcessor( + state: SetUpTwoFactorState( + allowDelay: true + ) + ) + ) + ) + } +} + +#Preview("Not Allowing Delay") { + NavigationView { + SetUpTwoFactorView( + store: Store( + processor: StateProcessor( + state: SetUpTwoFactorState( + allowDelay: false + ) + ) + ) + ) + } +} +#endif diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorViewTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorViewTests.swift new file mode 100644 index 000000000..bb1d70c83 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/SetUpTwoFactorViewTests.swift @@ -0,0 +1,84 @@ +import SnapshotTesting +import XCTest + +@testable import BitwardenShared + +// MARK: - SetUpTwoFactorViewTests + +class SetUpTwoFactorViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: SetUpTwoFactorView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + processor = MockProcessor(state: SetUpTwoFactorState(allowDelay: true)) + let store = Store(processor: processor) + + subject = SetUpTwoFactorView(store: store) + } + + override func tearDown() { + super.tearDown() + + processor = nil + subject = nil + } + + // MARK: Tests + + /// Tapping the change email button sends `.changeAccountEmailTapped` + @MainActor + func test_changeEmail_tap() throws { + let button = try subject.inspect().find(button: Localizations.changeAccountEmail) + try button.tap() + + XCTAssertEqual(processor.dispatchedActions.last, .changeAccountEmailTapped) + } + + /// Tapping the remind me later button sends `.remindMeLater` + @MainActor + func test_remindMeLater_tap() throws { + let button = try subject.inspect().find(button: Localizations.remindMeLater) + try button.tap() + + waitFor(!processor.effects.isEmpty) + + XCTAssertEqual(processor.effects.last, .remindMeLaterTapped) + } + + /// Tapping the turn on two-step login button sends `.changeAccountEmailTapped` + @MainActor + func test_turnOnTwoStep_tap() throws { + let button = try subject.inspect().find(button: Localizations.turnOnTwoStepLogin) + try button.tap() + + XCTAssertEqual(processor.dispatchedActions.last, .turnOnTwoFactorTapped) + } + + // MARK: Previews + + /// The set up two factor view renders correctly when delay is allowed + @MainActor + func test_snapshot_setUpTwoFactorView_allowDelay_true() { + processor.state.allowDelay = true + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5, .defaultLandscape] + ) + } + + /// The set up two factor view renders correctly when delay is allowed + @MainActor + func test_snapshot_setUpTwoFactorView_allowDelay_false() { + processor.state.allowDelay = false + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5, .defaultLandscape] + ) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.1.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.1.png new file mode 100644 index 000000000..64e0ed31a Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.1.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.2.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.2.png new file mode 100644 index 000000000..f1a0a6128 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.2.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.3.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.3.png new file mode 100644 index 000000000..d79fbcfb8 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.3.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.4.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.4.png new file mode 100644 index 000000000..a8ba39ab1 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_false.4.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.1.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.1.png new file mode 100644 index 000000000..ec680c299 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.1.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.2.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.2.png new file mode 100644 index 000000000..3921d769c Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.2.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.3.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.3.png new file mode 100644 index 000000000..d79fbcfb8 Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.3.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.4.png b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.4.png new file mode 100644 index 000000000..76916c69b Binary files /dev/null and b/BitwardenShared/UI/Auth/TwoFactorNotice/SetUpTwoFactor/__Snapshots__/SetUpTwoFactorViewTests/test_snapshot_setUpTwoFactorView_allowDelay_true.4.png differ diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TestHelpers/MockTwoFactorNoticeHelper.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TestHelpers/MockTwoFactorNoticeHelper.swift new file mode 100644 index 000000000..eea5e8154 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TestHelpers/MockTwoFactorNoticeHelper.swift @@ -0,0 +1,9 @@ +@testable import BitwardenShared + +class MockTwoFactorNoticeHelper: TwoFactorNoticeHelper { + var maybeShowTwoFactorNoticeCalled = false + + func maybeShowTwoFactorNotice() async { + maybeShowTwoFactorNoticeCalled = true + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeCoordinator.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeCoordinator.swift new file mode 100644 index 000000000..35407222b --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeCoordinator.swift @@ -0,0 +1,98 @@ +// MARK: - TwoFactorNoticeCoordinator + +/// A coordinator that manages navigation in the no-two-factor notice. +/// +final class TwoFactorNoticeCoordinator: Coordinator, HasStackNavigator { + // MARK: Types + + typealias Module = AuthModule + + typealias Services = HasApplication + & HasAuthRepository + & HasEnvironmentService + & HasErrorReporter + & HasStateService + & HasTimeProvider + + // MARK: Private Properties + + /// The delegate for this coordinator, used to notify when the user logs out. + private weak var delegate: VaultCoordinatorDelegate? + + /// The module used by this coordinator to create child coordinators. + private let module: Module + + /// The services used by this coordinator. + private let services: Services + + // MARK: Properties + + /// The stack navigator that is managed by this coordinator. + private(set) weak var stackNavigator: StackNavigator? + + // MARK: Initialization + + /// Creates a new `TwoFactorNoticeCoordinator`. + /// + /// - Parameters: + /// - module: The module used by this coordinator to create child coordinators. + /// - services: The services used by this coordinator. + /// - stackNavigator: The stack navigator that is managed by this coordinator. + /// + init( + module: Module, + services: Services, + stackNavigator: StackNavigator + ) { + self.module = module + self.services = services + self.stackNavigator = stackNavigator + } + + // MARK: Methods + + func navigate(to route: TwoFactorNoticeRoute, context: AnyObject?) { + switch route { + case .dismiss: + stackNavigator?.dismiss() + case let .emailAccess(allowDelay): + showEmailAccess(allowDelay) + case let .setUpTwoFactor(allowDelay): + showSetUpTwoFactor(allowDelay) + } + } + + func start() {} + + // MARK: Private Methods + + func showEmailAccess(_ allowDelay: Bool) { + let processor = EmailAccessProcessor( + coordinator: asAnyCoordinator(), + services: services, + state: EmailAccessState( + allowDelay: allowDelay + ) + ) + let store = Store(processor: processor) + let view = EmailAccessView( + store: store + ) + stackNavigator?.replace(view) + } + + func showSetUpTwoFactor(_ allowDelay: Bool) { + let processor = SetUpTwoFactorProcessor( + coordinator: asAnyCoordinator(), + services: services, + state: SetUpTwoFactorState( + allowDelay: allowDelay + ) + ) + let store = Store(processor: processor) + let view = SetUpTwoFactorView( + store: store + ) + stackNavigator?.push(view) + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeCoordinatorTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeCoordinatorTests.swift new file mode 100644 index 000000000..c434493d7 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeCoordinatorTests.swift @@ -0,0 +1,77 @@ +import SwiftUI +import XCTest + +@testable import BitwardenShared + +// MARK: - TwoFactorNoticeCoordinatorTests + +class TwoFactorNoticeCoordinatorTests: BitwardenTestCase { + // MARK: Properties + + // MARK: Properties + + var module: MockAppModule! + var stackNavigator: MockStackNavigator! + var subject: TwoFactorNoticeCoordinator! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + module = MockAppModule() + stackNavigator = MockStackNavigator() + + let services = ServiceContainer.withMocks() + + subject = TwoFactorNoticeCoordinator( + module: module, + services: services, + stackNavigator: stackNavigator + ) + } + + override func tearDown() { + super.tearDown() + + module = nil + stackNavigator = nil + subject = nil + } + + // MARK: Tests + + /// `navigate(to:)` with `.dismiss` dismisses the screen in the stack navigator. + @MainActor + func test_navigate_dismiss() throws { + subject.navigate(to: .dismiss) + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .dismissed) + } + + /// `navigate(to:)` with `.emailAccess` navigates to the email view. + @MainActor + func test_navigateTo_emailAccess() throws { + subject.navigate(to: .emailAccess(allowDelay: true)) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .replaced) + XCTAssertTrue(action.view is EmailAccessView) + } + + /// `navigate(to:)` with `.setUpTwoFactor` navigates to the set up two factor view. + @MainActor + func test_navigateTo_setUpTwoFactor() throws { + subject.navigate(to: .setUpTwoFactor(allowDelay: true)) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .pushed) + XCTAssertTrue(action.view is SetUpTwoFactorView) + } + + /// `start()` does nothing + @MainActor + func test_start() throws { + subject.start() + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeHelper.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeHelper.swift new file mode 100644 index 000000000..9018928b2 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeHelper.swift @@ -0,0 +1,112 @@ +import Foundation + +// MARK: - TwoFactorDisplayState + +/// An enum to track a user's status vis-à-vis the TwoFactorNotice notice screen +enum TwoFactorNoticeDisplayState: Codable, Equatable { + /// The user has seen the screen and indicated they can access their email. + case canAccessEmail + + /// The user has indicated they can access their email + /// as specified by the Permanent mode of the notice + case canAccessEmailPermanent + + /// The user has not seen the screen. + case hasNotSeen + + /// The user has seen the screen, at the indicated Date, and selected "remind me later". + case seen(Date) +} + +// MARK: - TwoFactorNoticeHelper + +/// A protocol for a helper object to handle deciding whether or not to display +/// the two-factor notice, and displaying it if so. +/// +protocol TwoFactorNoticeHelper { + /// + func maybeShowTwoFactorNotice() async +} + +// MARK: - DefaultTwoFactorNoticeHelper + +/// A default implementation of `TwoFactorNoticeHelper` +/// +@MainActor +class DefaultTwoFactorNoticeHelper: TwoFactorNoticeHelper { + // MARK: Types + + typealias Services = HasConfigService + & HasEnvironmentService + & HasErrorReporter + & HasPolicyService + & HasStateService + & HasTimeProvider + + // MARK: Private Properties + + /// The `Coordinator` that handles navigation. + private var coordinator: AnyCoordinator + + /// The services used by this helper. + private var services: Services + + // MARK: Initialization + + /// Initialize a `TwoFactorNoticeHelper`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - services: The services used by this helper. + /// + init( + coordinator: AnyCoordinator, + services: Services + ) { + self.coordinator = coordinator + self.services = services + } + + // MARK: Methods + + /// Checks if we need to display the notice for not having two-factor set up + /// and displays the notice if necessary + /// + func maybeShowTwoFactorNotice() async { + let temporary = await services.configService.getFeatureFlag( + .newDeviceVerificationTemporaryDismiss, + defaultValue: false + ) + let permanent = await services.configService.getFeatureFlag( + .newDeviceVerificationPermanentDismiss, + defaultValue: false + ) + guard temporary || permanent else { return } + do { + guard services.environmentService.region != .selfHosted, + try await !services.stateService.doesActiveAccountHaveTwoFactor(), + await !services.policyService.policyAppliesToUser(.requireSSO) + else { return } + + let state = try await services.stateService.getTwoFactorNoticeDisplayState() + switch state { + case .canAccessEmail: + if permanent { + coordinator.navigate(to: .twoFactorNotice(!permanent)) + } else { + return + } + case .canAccessEmailPermanent: + return + case .hasNotSeen: + coordinator.navigate(to: .twoFactorNotice(!permanent)) + case let .seen(date): + if services.timeProvider.timeSince(date) >= (86400 * 7) { // Seven days + coordinator.navigate(to: .twoFactorNotice(!permanent)) + } + } + } catch { + services.errorReporter.log(error: error) + } + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeHelperTests.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeHelperTests.swift new file mode 100644 index 000000000..bbad26cb1 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeHelperTests.swift @@ -0,0 +1,254 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - TwoFactorNoticeHelperTests + +class TwoFactorNoticeHelperTests: BitwardenTestCase { + // MARK: Properties + + var configService: MockConfigService! + var coordinator: MockCoordinator! + var environmentService: MockEnvironmentService! + var errorReporter: MockErrorReporter! + var policyService: MockPolicyService! + var stateService: MockStateService! + var subject: DefaultTwoFactorNoticeHelper! + var timeProvider: MockTimeProvider! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + configService = MockConfigService() + coordinator = MockCoordinator() + environmentService = MockEnvironmentService() + errorReporter = MockErrorReporter() + policyService = MockPolicyService() + stateService = MockStateService() + timeProvider = MockTimeProvider(.mockTime(Date(year: 2024, month: 6, day: 15, hour: 12, minute: 0))) + + let services = ServiceContainer.withMocks( + configService: configService, + environmentService: environmentService, + errorReporter: errorReporter, + policyService: policyService, + stateService: stateService, + timeProvider: timeProvider + ) + + subject = DefaultTwoFactorNoticeHelper( + coordinator: coordinator.asAnyCoordinator(), + services: services + ) + + // Because nearly all of the tests are "it doesn't show the notice if + // these conditions are true", it makes sense to set things up here to + // show the notice, and then in tests selectively set up the specific + // condition that causes it to not show. This hopefully makes the tests + // easier to read. + stateService.activeAccount = .fixture() + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = true + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = true + environmentService.region = .unitedStates + stateService.doesActiveAccountHaveTwoFactorResult = .success(false) + policyService.policyAppliesToUserResult[.requireSSO] = false + } + + override func tearDown() { + super.tearDown() + + configService = nil + coordinator = nil + environmentService = nil + errorReporter = nil + policyService = nil + stateService = nil + subject = nil + } + + // MARK: Tests based on properties of the account itself + + /// `.maybeShowTwoFactorNotice()` will show the notice + /// if the user does not have a 2FA method configured, + /// is not self-hosted + /// and is not SSO-only + /// + @MainActor + func test_maybeShow() async { + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, [.twoFactorNotice(false)]) + } + + /// `.maybeShowTwoFactorNotice()` will not show the notice + /// if the user already has a 2FA method configured + @MainActor + func test_maybeShow_preexistingTwoFactor() async { + stateService.doesActiveAccountHaveTwoFactorResult = .success(true) + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + /// `.maybeShowTwoFactorNotice()` will show the notice + /// if the user is in the Europe region + @MainActor + func test_maybeShow_server_europe() async { + environmentService.region = .europe + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, [.twoFactorNotice(false)]) + } + + /// `.maybeShowTwoFactorNotice()` will not show the notice + /// if the user is self-hosted + @MainActor + func test_maybeShow_server_selfHosted() async { + environmentService.region = .selfHosted + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + /// `.maybeShowTwoFactorNotice()` will not show the notice + /// if the user is SSO-only + /// + /// policyService.policyAppliesToUser(.requresSso) + @MainActor + func test_maybeShow_ssoOnly() async { + policyService.policyAppliesToUserResult[.requireSSO] = true + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + // MARK: Tests based on feature flags and notice display state + + /// `.maybeShowTwoFactorNotice()` will not show the notice if both feature flags are off. + @MainActor + func test_maybeShow_neitherFeatureFlag() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = false + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = false + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + /// `.maybeShowTwoFactorNotice()` will not show the notice + /// if the user indicated they can access the email in temporary mode + /// and we are still in temporary mode + @MainActor + func test_maybeShow_canAccessEmail_temporary() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = true + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = false + stateService.twoFactorNoticeDisplayState["1"] = .canAccessEmail + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + /// `.maybeShowTwoFactorNotice()` will show the notice + /// if the user indicated they can access the email in temporary mode + /// and we are in permanent mode + @MainActor + func test_maybeShow_canAccessEmail_permanent() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = false + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = true + stateService.twoFactorNoticeDisplayState["1"] = .canAccessEmail + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, [.twoFactorNotice(false)]) + } + + /// `.maybeShowTwoFactorNotice()` will not show the notice + /// if the user indicated they can access the email in permanent mode + @MainActor + func test_maybeShow_canAccessEmailPermanent() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = true + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = true + stateService.twoFactorNoticeDisplayState["1"] = .canAccessEmailPermanent + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + /// `.maybeShowTwoFactorNotice()` will show the notice + /// if the user has not seen it + @MainActor + func test_maybeShow_hasNotSeen_permanent() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = false + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = true + stateService.twoFactorNoticeDisplayState["1"] = .hasNotSeen + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, [.twoFactorNotice(false)]) + } + + /// `.maybeShowTwoFactorNotice()` will show the notice + /// if the user has not seen it + @MainActor + func test_maybeShow_hasNotSeen_temporary() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = true + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = false + stateService.twoFactorNoticeDisplayState["1"] = .hasNotSeen + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, [.twoFactorNotice(true)]) + } + + /// `.maybeShowTwoFactorNotice()` will not show the notice + /// if the user last saw it less than seven days ago + @MainActor + func test_maybeShow_seenLessThenSevenDays() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = true + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = false + stateService.twoFactorNoticeDisplayState["1"] = .seen(Date(year: 2024, month: 6, day: 8, hour: 12, minute: 1)) + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, []) + } + + /// `.maybeShowTwoFactorNotice()` will show the notice + /// if the user last saw it more than seven days ago + @MainActor + func test_maybeShow_seenMoreThenSevenDays() async { + configService.featureFlagsBool[.newDeviceVerificationTemporaryDismiss] = true + configService.featureFlagsBool[.newDeviceVerificationPermanentDismiss] = false + stateService.twoFactorNoticeDisplayState["1"] = .seen(Date(year: 2024, month: 6, day: 8, hour: 11, minute: 59)) + environmentService.region = .unitedStates + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual(coordinator.routes, [.twoFactorNotice(true)]) + } + + // MARK: Other tests + + /// `.maybeShowTwoFactorNotice()` handles errors + @MainActor + func test_maybeShow_error() async { + stateService.twoFactorNoticeDisplayStateError = BitwardenTestError.example + + await subject.maybeShowTwoFactorNotice() + + XCTAssertEqual( + errorReporter.errors.last as? BitwardenTestError, + BitwardenTestError.example + ) + XCTAssertEqual(coordinator.routes, []) + } + +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeModule.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeModule.swift new file mode 100644 index 000000000..eb1f3591f --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeModule.swift @@ -0,0 +1,30 @@ +import Foundation + +// MARK: - TwoFactorNoticeModule + +/// An object that builds coordinators for the No Two Factor notice. +@MainActor +protocol TwoFactorNoticeModule { + /// Initializes a coordinator for navigating between `TwoFactorNoticeRoute`s. + /// + /// - Parameters: + /// - delegate: A delegate of the `TwoFactorNoticeCoordinator`. + /// - stackNavigator: The stack navigator that will be used to navigate between routes. + /// - Returns: A coordinator that can navigate to `TwoFactorNoticeRoute`s. + /// + func makeTwoFactorNoticeCoordinator( + stackNavigator: StackNavigator + ) -> AnyCoordinator +} + +extension DefaultAppModule: TwoFactorNoticeModule { + func makeTwoFactorNoticeCoordinator( + stackNavigator: StackNavigator + ) -> AnyCoordinator { + TwoFactorNoticeCoordinator( + module: self, + services: services, + stackNavigator: stackNavigator + ).asAnyCoordinator() + } +} diff --git a/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeRoute.swift b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeRoute.swift new file mode 100644 index 000000000..f57b53373 --- /dev/null +++ b/BitwardenShared/UI/Auth/TwoFactorNotice/TwoFactorNoticeRoute.swift @@ -0,0 +1,13 @@ +// MARK: - TwoFactorNoticeRoute + +/// A route to a specific screen in the No Two Factor notice. +public enum TwoFactorNoticeRoute: Equatable, Hashable { + /// A route to dismiss the screen currently presented modally. + case dismiss + + /// A route to the email access screen. + case emailAccess(allowDelay: Bool) + + /// A route to the screen to set up two-factor authentication. + case setUpTwoFactor(allowDelay: Bool) +} diff --git a/BitwardenShared/UI/Platform/Application/AppModuleTests.swift b/BitwardenShared/UI/Platform/Application/AppModuleTests.swift index 664f271e2..6381122f7 100644 --- a/BitwardenShared/UI/Platform/Application/AppModuleTests.swift +++ b/BitwardenShared/UI/Platform/Application/AppModuleTests.swift @@ -161,6 +161,18 @@ class AppModuleTests: BitwardenTestCase { XCTAssertTrue(rootViewController.childViewController === tabBarController) } + /// `makeTwoFactorNoticeCoordinator` builds the two factor notice coordinator. + @MainActor + func test_makeTwoFactorNoticeCoordinator() { + let navigationController = UINavigationController() + let coordinator = subject.makeTwoFactorNoticeCoordinator( + stackNavigator: navigationController + ) + coordinator.navigate(to: .emailAccess(allowDelay: true)) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertTrue(navigationController.viewControllers[0] is UIHostingController) + } + /// `makeVaultCoordinator()` builds the vault coordinator. @MainActor func test_makeVaultCoordinator() { diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/Contents.json b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/Contents.json new file mode 100644 index 000000000..7678a2061 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "business-warning.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "business-warning-dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/business-warning-dark.svg b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/business-warning-dark.svg new file mode 100644 index 000000000..19263c993 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/business-warning-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/business-warning.svg b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/business-warning.svg new file mode 100644 index 000000000..d6efde5b7 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/business-warning.imageset/business-warning.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/Contents.json b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/Contents.json new file mode 100644 index 000000000..40d845136 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "user-lock.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "user-lock-dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/user-lock-dark.svg b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/user-lock-dark.svg new file mode 100644 index 000000000..c12b3fd46 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/user-lock-dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/user-lock.svg b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/user-lock.svg new file mode 100644 index 000000000..a9539077f --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/Illustrations/user-lock.imageset/user-lock.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 05d15b926..18fd23230 100644 --- a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -1059,8 +1059,17 @@ "CopyPrivateKey" = "Copy private key"; "CopyFingerprint" = "Copy fingerprint"; "SSHKeys" = "SSH keys"; +"ImportantNotice" = "Important notice"; +"BitwardenWillSendACodeToYourAccountEmail" = "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025."; +"DoYouHaveReliableAccessToYourEmail" = "Do you have reliable access to your email, **%1$@**?"; +"YesICanReliablyAccessMyEmail" = "Yes, I can reliably access my email"; +"SetUpTwoStepLogin" = "Set up two-step login"; +"YouCanSetUpTwoStepLoginAsAnAlternative" = "You can set up two-step login as an alternative way to protect your account or change your email to one you can access."; +"TurnOnTwoStepLogin" = "Turn on two-step login"; +"ChangeAccountEmail" = "Change account email"; "NothingAvailableToAutofill" = "Nothing available to autofill"; "FailedToGenerateVerificationCode" = "Failed to generate verification code"; "FailedToAutofillItem" = "Failed to autofill item %1$@"; "ExportingFailed" = "Exporting failed"; "YouMayNeedToEnableDevicePasscodeOrBiometrics" = "You may need to enable device passcode or biometrics."; +"TurnOnTwoStepLoginConfirmation" = "You can turn on two-step login on the bitwarden.com web vault. Do you want to visit the website now?"; diff --git a/BitwardenShared/UI/Platform/Application/Views/DynamicImageTextStackView.swift b/BitwardenShared/UI/Platform/Application/Views/DynamicImageTextStackView.swift new file mode 100644 index 000000000..6777e4a1e --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/DynamicImageTextStackView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +// MARK: - DynamicImageTextStackView + +/// A dynamic stack view that lays out content vertically when in a regular vertical size class +/// and horizontally for the compact vertical size class. +/// In iOS 16+, this might be accomplishable with a ViewThatFits +struct DynamicImageTextStackView: View { + /// An environment variable for getting the vertical size class of the view. + @Environment(\.verticalSizeClass) var verticalSizeClass + + // The minimum height to lay the contents out in. + private let minHeight: CGFloat + + // The image content of the view; in a vertical size class this is above the text + // and in a horizontal size class is on the leading side of the text. + private let imageContent: I + + // The text content of the view; in a vertical size class this is below the image + // and in a horizontal size class is on the trailing side of the image. + private let textContent: T + + var body: some View { + if verticalSizeClass == .regular { + VStack(spacing: 24) { + imageContent + textContent + } + .padding(.top, 32) + .padding(.bottom, 24) + .frame(maxWidth: .infinity, minHeight: minHeight) + } else { + HStack(alignment: .top, spacing: 40) { + VStack(spacing: 0) { + Spacer(minLength: 0) + imageContent + .padding(.leading, 36) + .padding(.vertical, 16) + Spacer(minLength: 0) + } + .frame(minHeight: minHeight) + + textContent + .padding(.vertical, 16) + .frame(maxWidth: .infinity, minHeight: minHeight) + } + } + } + + // MARK: Initialization + + /// Creates a new `DynamicImageTextStackView`. This view lays out content + /// vertically when in a regular vertical size class + /// and horizontally for the compact vertical size class. + /// + /// - Parameters: + /// - minHeight: The minimum height to lay the contents out in. + /// - imageContent: The image content of the view; + /// in a vertical size class this is above the text + /// and in a horizontal size class is on the leading side of the text. + /// - textContent: The text content of the view; + /// in a vertical size class this is below the image + /// and in a horizontal size class is on the trailing side of the image. + /// + init( + minHeight: CGFloat, + @ViewBuilder imageContent: @escaping () -> I, + @ViewBuilder textContent: @escaping () -> T + ) { + self.minHeight = minHeight + self.imageContent = imageContent() + self.textContent = textContent() + } + +} diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift index 3480dcf51..382abc7a1 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift @@ -59,6 +59,7 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { typealias Module = GeneratorModule & ImportLoginsModule + & TwoFactorNoticeModule & VaultItemModule typealias Services = HasApplication @@ -197,6 +198,8 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { showList() case let .loginRequest(loginRequest): delegate?.presentLoginRequest(loginRequest) + case let .twoFactorNotice(allowDelay): + showTwoFactorNotice(allowDelay) case let .vaultItemSelection(totpKeyModel): showVaultItemSelection(totpKeyModel: totpKeyModel) case let .viewItem(id): @@ -318,6 +321,10 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { state: VaultListState( iconBaseURL: services.environmentService.iconsURL ), + twoFactorNoticeHelper: DefaultTwoFactorNoticeHelper( + coordinator: asAnyCoordinator(), + services: services + ), vaultItemMoreOptionsHelper: DefaultVaultItemMoreOptionsHelper( coordinator: asAnyCoordinator(), services: services @@ -331,6 +338,21 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { stackNavigator?.replace(view, animated: false) } + /// Shows the notice that the user does not have two-factor set up. + /// + private func showTwoFactorNotice(_ allowDelay: Bool) { + let navigationController = UINavigationController() + navigationController.navigationBar.isHidden = true + let coordinator = module.makeTwoFactorNoticeCoordinator(stackNavigator: navigationController) + coordinator.start() + coordinator.navigate( + to: .emailAccess(allowDelay: allowDelay), + context: delegate + ) + + stackNavigator?.present(navigationController, overFullscreen: true) + } + /// Presents a vault item coordinator, which will navigate to the provided route. /// /// - Parameter route: The route to navigate to in the coordinator. diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift index 1bf43b573..dd8656fd3 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift @@ -277,6 +277,17 @@ class VaultCoordinatorTests: BitwardenTestCase { XCTAssertEqual(delegate.accountTapped, ["123"]) } + /// `navigate(to:)` with `.twoFactorNotice` presents the two-factor notice screen. + @MainActor + func test_navigateTo_twoFactorNotice() throws { + subject.navigate(to: .twoFactorNotice(true)) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertTrue(module.twoFactorNoticeCoordinator.isStarted) + XCTAssertEqual(module.twoFactorNoticeCoordinator.routes.last, .emailAccess(allowDelay: true)) + } + /// `.navigate(to:)` with `.vaultItemSelection` presents the vault item selection screen. @MainActor func test_navigateTo_vaultItemSelection() throws { diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift index b2eb66fd5..cbb1909fa 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -39,6 +39,9 @@ final class VaultListProcessor: StateProcessor< /// The services used by this processor. private let services: Services + /// The helper to handle the two-factor notice. + private let twoFactorNoticeHelper: TwoFactorNoticeHelper + /// The helper to handle the more options menu for a vault item. private let vaultItemMoreOptionsHelper: VaultItemMoreOptionsHelper @@ -56,10 +59,12 @@ final class VaultListProcessor: StateProcessor< coordinator: AnyCoordinator, services: Services, state: VaultListState, + twoFactorNoticeHelper: TwoFactorNoticeHelper, vaultItemMoreOptionsHelper: VaultItemMoreOptionsHelper ) { self.coordinator = coordinator self.services = services + self.twoFactorNoticeHelper = twoFactorNoticeHelper self.vaultItemMoreOptionsHelper = vaultItemMoreOptionsHelper super.init(state: state) } @@ -77,6 +82,7 @@ final class VaultListProcessor: StateProcessor< await handleNotifications() await checkPendingLoginRequests() await checkPersonalOwnershipPolicy() + await twoFactorNoticeHelper.maybeShowTwoFactorNotice() case .checkAppReviewEligibility: if await services.reviewPromptService.isEligibleForReviewPrompt() { await scheduleReviewPrompt() diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index ca17f7547..055a9ff4a 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -23,6 +23,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ var stateService: MockStateService! var subject: VaultListProcessor! var timeProvider: MockTimeProvider! + var twoFactorNoticeHelper: MockTwoFactorNoticeHelper! var vaultItemMoreOptionsHelper: MockVaultItemMoreOptionsHelper! var vaultRepository: MockVaultRepository! @@ -47,6 +48,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ reviewPromptService = MockReviewPromptService() stateService = MockStateService() timeProvider = MockTimeProvider(.mockTime(Date(year: 2024, month: 6, day: 28))) + twoFactorNoticeHelper = MockTwoFactorNoticeHelper() vaultItemMoreOptionsHelper = MockVaultItemMoreOptionsHelper() vaultRepository = MockVaultRepository() let services = ServiceContainer.withMocks( @@ -68,6 +70,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ coordinator: coordinator.asAnyCoordinator(), services: services, state: VaultListState(), + twoFactorNoticeHelper: twoFactorNoticeHelper, vaultItemMoreOptionsHelper: vaultItemMoreOptionsHelper ) } @@ -444,6 +447,14 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(stateService.notificationsLastRegistrationDates["1"], timeProvider.presentTime) } + /// `perform(_:)` with `.appeared` calls the two-factor notice helper + @MainActor + func test_perform_appeared_twoFactorHelper() async throws { + await subject.perform(.appeared) + + XCTAssertTrue(twoFactorNoticeHelper.maybeShowTwoFactorNoticeCalled) + } + /// `perform(_:)` with `.dismissImportLoginsActionCard` sets the user's import logins setup /// progress to complete. @MainActor diff --git a/BitwardenShared/UI/Vault/Vault/VaultRoute.swift b/BitwardenShared/UI/Vault/Vault/VaultRoute.swift index 5fe49bfd5..41f7a6b9a 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultRoute.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultRoute.swift @@ -57,6 +57,8 @@ public enum VaultRoute: Equatable, Hashable { /// case loginRequest(_ loginRequest: LoginRequest) + case twoFactorNotice(_ allowDelay: Bool) + /// A route to switch accounts. /// /// - Parameter userId: The user id of the selected account. diff --git a/GlobalTestHelpers/MockAppModule.swift b/GlobalTestHelpers/MockAppModule.swift index f6f75dd86..c5ddc5401 100644 --- a/GlobalTestHelpers/MockAppModule.swift +++ b/GlobalTestHelpers/MockAppModule.swift @@ -17,6 +17,7 @@ class MockAppModule: SendItemModule, SettingsModule, TabModule, + TwoFactorNoticeModule, VaultModule, VaultItemModule { var appCoordinator = MockCoordinator() @@ -39,6 +40,7 @@ class MockAppModule: var settingsCoordinator = MockCoordinator() var settingsNavigator: StackNavigator? // swiftlint:disable:this weak_navigator var tabCoordinator = MockCoordinator() + var twoFactorNoticeCoordinator = MockCoordinator() var vaultCoordinator = MockCoordinator() var vaultItemCoordinator = MockCoordinator() @@ -148,6 +150,12 @@ class MockAppModule: tabCoordinator.asAnyCoordinator() } + func makeTwoFactorNoticeCoordinator( + stackNavigator: StackNavigator + ) -> AnyCoordinator { + twoFactorNoticeCoordinator.asAnyCoordinator() + } + func makeVaultCoordinator( delegate _: BitwardenShared.VaultCoordinatorDelegate, stackNavigator _: BitwardenShared.StackNavigator