BIT-1507: Pending login requests view (#358)

Co-authored-by: Matt Czech <matt@livefront.com>
This commit is contained in:
Shannon Draeker 2024-01-22 13:12:11 -07:00 committed by GitHub
parent 786c4291c1
commit 18f0a9192b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1238 additions and 79 deletions

View File

@ -0,0 +1,35 @@
import Foundation
@testable import BitwardenShared
extension LoginRequest {
static func fixture(
creationDate: Date = Date(year: 3000, month: 1, day: 1),
fingerprintPhrase: String? = nil,
id: String = "1",
key: String? = "reallyLongKey",
masterPasswordHash: String? = "reallyLongMasterPasswordHash",
origin: String = "vault.bitwarden.com",
publicKey: String = "reallyLongPublicKey",
requestAccessCode: String? = nil,
requestApproved: Bool? = nil,
requestDeviceType: String = "iOS",
requestIpAddress: String = "11.22.333.444",
responseDate: Date? = nil
) -> LoginRequest {
LoginRequest(
creationDate: creationDate,
fingerprintPhrase: fingerprintPhrase,
id: id,
key: key,
origin: origin,
masterPasswordHash: masterPasswordHash,
publicKey: publicKey,
requestAccessCode: requestAccessCode,
requestApproved: requestApproved,
requestDeviceType: requestDeviceType,
requestIpAddress: requestIpAddress,
responseDate: responseDate
)
}
}

View File

@ -0,0 +1,52 @@
import Foundation
import Networking
// MARK: - LoginRequest
/// A data structure representing a login request.
///
public struct LoginRequest: JSONResponse, Equatable {
public static var decoder = JSONDecoder.defaultDecoder
// MARK: Properties
/// The creation date of the login request.
let creationDate: Date
/// The fingerprint phrase of the login request.
var fingerprintPhrase: String?
/// The id of the login request.
public let id: String
/// The key of the login request.
let key: String?
/// The origin of the login request.
let origin: String
/// The master password hash of the login request.
let masterPasswordHash: String?
/// The public key of the login request.
let publicKey: String
/// The access code of the login request.
let requestAccessCode: String?
/// Whether the login request has been approved.
let requestApproved: Bool?
/// The device of the login request, eg 'iOS'.
let requestDeviceType: String
/// The IP address of the request.
let requestIpAddress: String
/// The response date, if the login request has already been approved or denied.
let responseDate: Date?
}
extension LoginRequest: Identifiable {}
extension LoginRequest: Hashable {}

View File

@ -0,0 +1,15 @@
import Foundation
import Networking
// MARK: - PendingLoginsResponse
/// The response returned from the API when requesting the pending login requests.
///
struct PendingLoginsResponse: JSONResponse {
static var decoder = JSONDecoder.defaultDecoder
// MARK: Properties
/// The data returned by the API request.
let data: [LoginRequest]
}

View File

@ -1,3 +1,5 @@
import Foundation
/// A protocol for an API service used to make auth requests.
///
protocol AuthAPIService {
@ -8,6 +10,12 @@ protocol AuthAPIService {
///
func getIdentityToken(_ request: IdentityTokenRequestModel) async throws -> IdentityTokenResponseModel
/// Gets the pending login requests.
///
/// - Returns: The pending login requests.
///
func getPendingLoginRequests() async throws -> [LoginRequest]
/// Query the API to determine if the user's email is able to use single sign on and if the organization
/// identifier is already known.
///
@ -43,6 +51,24 @@ extension APIService: AuthAPIService {
try await identityService.send(IdentityTokenRequest(requestModel: request))
}
func getPendingLoginRequests() async throws -> [LoginRequest] {
// Filter the response to only show the non-expired, non-answered requests.
try await apiService.send(PendingLoginsRequest())
.data
.filter { request in
let isAnswered = request.requestApproved != nil && request.responseDate != nil
let expirationDate = Calendar.current.date(
byAdding: .minute,
value: Constants.loginRequestTimeoutMinutes,
to: request.creationDate
) ?? Date()
let isExpired = expirationDate < Date()
return !isAnswered && !isExpired
}
}
func getSingleSignOnDetails(email: String) async throws -> SingleSignOnDetailsResponse {
try await apiUnauthenticatedService.send(SingleSignOnDetailsRequest(email: email))
}

View File

@ -64,6 +64,15 @@ class AuthAPIServiceTests: BitwardenTestCase {
}
}
/// `getPendingLoginRequests()` successfully decodes the pending login requests response.
func test_getPendingLoginRequests() async throws {
client.result = .httpSuccess(testData: .authRequestSuccess)
let response = try await subject.getPendingLoginRequests()
XCTAssertEqual(response, [.fixture()])
}
/// `getSingleSignOnDetails(email:)` successfully decodes the single sign on details response.
func test_getSingleSignOnDetails() async throws {
client.result = .httpSuccess(testData: .singleSignOnDetails)

View File

@ -1,6 +1,7 @@
extension APITestData {
// MARK: Identity Token
static let authRequestSuccess = loadFromJsonBundle(resource: "AuthRequests")
static let emptyResponse = APITestData(data: "{}".data(using: .utf8)!)
static let identityTokenSuccess = loadFromJsonBundle(resource: "IdentityTokenSuccess")
static let identityTokenSuccessTwoFactorToken = loadFromJsonBundle(resource: "IdentityTokenSuccessTwoFactorToken")

View File

@ -0,0 +1,17 @@
{
"data": [
{
"id": "1",
"publicKey": "reallyLongPublicKey",
"requestDeviceType": "iOS",
"requestIpAddress": "11.22.333.444",
"key": "reallyLongKey",
"masterPasswordHash": "reallyLongMasterPasswordHash",
"creationDate": "3000-01-01T00:00:00.00Z",
"origin": "vault.bitwarden.com",
"object": "auth-request"
}
],
"continuationToken": null,
"object": "list"
}

View File

@ -0,0 +1,18 @@
import Foundation
import Networking
// MARK: - PendingLoginsRequest
/// A request for getting the pending login requests.
///
struct PendingLoginsRequest: Request {
typealias Response = PendingLoginsResponse
// MARK: Properties
/// The HTTP method for this request.
var method: HTTPMethod { .get }
/// The URL path for this request.
var path: String { "/auth-requests" }
}

View File

@ -0,0 +1,39 @@
import Networking
import XCTest
@testable import BitwardenShared
class PendingLoginsRequestTests: BitwardenTestCase {
// MARK: Properties
var subject: PendingLoginsRequest!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = PendingLoginsRequest()
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// `body` is nil
func test_body() throws {
XCTAssertNil(subject.body)
}
/// `method` returns the method of the request.
func test_method() {
XCTAssertEqual(subject.method, .get)
}
/// `path` returns the path of the request.
func test_path() {
XCTAssertEqual(subject.path, "/auth-requests")
}
}

View File

@ -40,6 +40,10 @@ protocol AuthService {
///
func generateSingleSignOnUrl(from organizationIdentifier: String) async throws -> (url: URL, state: String)
/// Get all the pending login requests.
///
func getPendingLoginRequests() async throws -> [LoginRequest]
/// Creates a hash value for the user's master password.
///
/// - Parameters:
@ -112,11 +116,14 @@ class DefaultAuthService: AuthService {
let callbackUrlScheme = "bitwarden"
/// The client used by the application to handle auth related encryption and decryption tasks.
let clientAuth: ClientAuthProtocol
private let clientAuth: ClientAuthProtocol
/// The client used for generating passwords and passphrases.
private let clientGenerators: ClientGeneratorsProtocol
/// The client used by the application to handle account fingerprint phrase generation.
private let clientPlatform: ClientPlatformProtocol
/// The code verifier used to login after receiving the code from the WebAuth.
private var codeVerifier = ""
@ -149,6 +156,7 @@ class DefaultAuthService: AuthService {
/// - authAPIService: The API service used to make calls related to the auth process.
/// - clientAuth: The client used by the application to handle auth related encryption and decryption tasks.
/// - clientGenerators: The client used for generating passwords and passphrases.
/// - clientPlatform: The client used by the application to handle account fingerprint phrase generation.
/// - environmentService: The service used by the application to manage the environment settings.
/// - stateService: The object used by the application to retrieve information about this device.
/// - systemDevice: The object used by the application to retrieve information about this device.
@ -159,6 +167,7 @@ class DefaultAuthService: AuthService {
authAPIService: AuthAPIService,
clientAuth: ClientAuthProtocol,
clientGenerators: ClientGeneratorsProtocol,
clientPlatform: ClientPlatformProtocol,
environmentService: EnvironmentService,
stateService: StateService,
systemDevice: SystemDevice
@ -168,6 +177,7 @@ class DefaultAuthService: AuthService {
self.authAPIService = authAPIService
self.clientAuth = clientAuth
self.clientGenerators = clientGenerators
self.clientPlatform = clientPlatform
self.environmentService = environmentService
self.stateService = stateService
self.systemDevice = systemDevice
@ -222,6 +232,20 @@ class DefaultAuthService: AuthService {
return (url, state)
}
func getPendingLoginRequests() async throws -> [LoginRequest] {
// Get the pending login requests.
var loginRequests = try await authAPIService.getPendingLoginRequests()
// Use the user's email to decode the fingerprint phrase for each request.
let userEmail = try await stateService.getActiveAccount().profile.email
loginRequests = try await loginRequests.asyncMap { request in
var request = request
request.fingerprintPhrase = try await self.getFingerprintPhrase(from: request.publicKey, email: userEmail)
return request
}
return loginRequests
}
func loginWithMasterPassword(_ masterPassword: String, username: String, captchaToken: String?) async throws {
// Complete the pre-login steps.
let response = try await accountAPIService.preLogin(email: username)
@ -310,6 +334,21 @@ class DefaultAuthService: AuthService {
// MARK: Private Methods
/// Get the fingerprint phrase from the public key of a login request.
///
/// - Parameters:
/// - publicKey: The public key of a login request.
/// - email: The user's email.
///
/// - Returns: The fingerprint phrase.
///
private func getFingerprintPhrase(from publicKey: String, email: String) async throws -> String {
try await clientPlatform.fingerprint(req: .init(
fingerprintMaterial: email,
publicKey: publicKey.urlDecoded()
))
}
/// Get an identity token and handle the response.
///
/// - Parameters:
@ -390,4 +429,4 @@ class DefaultAuthService: AuthService {
throw error
}
}
}
} // swiftlint:disable:this file_length

View File

@ -4,7 +4,7 @@ import XCTest
// MARK: - AuthServiceTests
class AuthServiceTests: BitwardenTestCase {
class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var accountAPIService: AccountAPIService!
@ -13,6 +13,7 @@ class AuthServiceTests: BitwardenTestCase {
var client: MockHTTPClient!
var clientAuth: MockClientAuth!
var clientGenerators: MockClientGenerators!
var clientPlatform: MockClientPlatform!
var environmentService: MockEnvironmentService!
var stateService: MockStateService!
var subject: DefaultAuthService!
@ -29,6 +30,7 @@ class AuthServiceTests: BitwardenTestCase {
authAPIService = APIService(client: client)
clientAuth = MockClientAuth()
clientGenerators = MockClientGenerators()
clientPlatform = MockClientPlatform()
environmentService = MockEnvironmentService()
stateService = MockStateService()
systemDevice = MockSystemDevice()
@ -39,6 +41,7 @@ class AuthServiceTests: BitwardenTestCase {
authAPIService: authAPIService,
clientAuth: clientAuth,
clientGenerators: clientGenerators,
clientPlatform: clientPlatform,
environmentService: environmentService,
stateService: stateService,
systemDevice: systemDevice
@ -54,6 +57,7 @@ class AuthServiceTests: BitwardenTestCase {
client = nil
clientAuth = nil
clientGenerators = nil
clientPlatform = nil
environmentService = nil
stateService = nil
subject = nil
@ -93,6 +97,16 @@ class AuthServiceTests: BitwardenTestCase {
XCTAssertEqual("PASSWORD", result.1)
}
/// `getPendingLoginRequests()` returns all the active pending login requests.
func test_getPendingLoginRequests() async throws {
stateService.activeAccount = .fixture()
client.result = .httpSuccess(testData: .authRequestSuccess)
let result = try await subject.getPendingLoginRequests()
XCTAssertEqual(result, [.fixture(fingerprintPhrase: "a-fingerprint-phrase-string-placeholder")])
}
/// `loginWithMasterPassword(_:username:captchaToken:)` logs in with the password.
func test_loginWithMasterPassword() async throws {
// Set up the mock data.

View File

@ -9,6 +9,9 @@ class MockAuthService: AuthService {
var generateSingleSignOnUrlResult: Result<(URL, String), Error> = .success((url: .example, state: "state"))
var generateSingleSignOnOrgIdentifier: String?
var getPendingLoginRequestsCalled = false
var getPendingLoginRequestsResult: Result<[LoginRequest], Error> = .success([])
var hashPasswordPassword: String?
var hashPasswordResult: Result<String, Error> = .success("hashed")
@ -34,6 +37,11 @@ class MockAuthService: AuthService {
return try generateSingleSignOnUrlResult.get()
}
func getPendingLoginRequests() async throws -> [LoginRequest] {
getPendingLoginRequestsCalled = true
return try getPendingLoginRequestsResult.get()
}
func hashPassword(password: String, purpose _: HashPurpose) async throws -> String {
hashPasswordPassword = password
return try hashPasswordResult.get()

View File

@ -261,6 +261,7 @@ public class ServiceContainer: Services {
authAPIService: apiService,
clientAuth: clientService.clientAuth(),
clientGenerators: clientService.clientGenerator(),
clientPlatform: clientService.clientPlatform(),
environmentService: environmentService,
stateService: stateService,
systemDevice: UIDevice.current

View File

@ -62,6 +62,14 @@ protocol StateService: AnyObject {
///
func getAllowSyncOnRefresh(userId: String?) async throws -> Bool
/// Gets whether the user has decided to allow the device to approve login requests.
///
/// - Parameter userId: The user ID associated with the setting. Defaults to the active account if `nil`.
///
/// - Returns: Whether the user has decided to allow the device to approve login requests.
///
func getApproveLoginRequests(userId: String?) async throws -> Bool
/// Get the app theme.
///
/// - Returns: The app theme.
@ -203,6 +211,14 @@ protocol StateService: AnyObject {
///
func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool, userId: String?) async throws
/// Sets whether the user has decided to allow the device to approve login requests.
///
/// - Parameters:
/// - approveLoginRequests: Whether the user has decided to allow the device to approve login requests.
/// - userId: The user ID associated with the setting. Defaults to the active account if `nil`.
///
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String?) async throws
/// Sets the app theme.
///
/// - Parameter appTheme: The new app theme.
@ -373,6 +389,16 @@ extension StateService {
try await getAllowSyncOnRefresh(userId: nil)
}
/// Gets whether the current user has decided to allow the device to approve login requests.
///
/// - Parameter userId: The user ID associated with the setting. Defaults to the active account if `nil`.
///
/// - Returns: Whether the current user has decided to allow the device to approve login requests.
///
func getApproveLoginRequests() async throws -> Bool {
try await getApproveLoginRequests(userId: nil)
}
/// Gets the clear clipboard value for the active account.
///
/// - Returns: The clear clipboard value.
@ -470,6 +496,14 @@ extension StateService {
try await setAllowSyncOnRefresh(allowSyncOnRefresh, userId: nil)
}
/// Sets whether the current user has decided to allow the device to approve login requests.
///
/// - Parameter approveLoginRequests: Whether the user has decided to allow the device to approve login requests.
///
func setApproveLoginRequests(_ approveLoginRequests: Bool) async throws {
try await setApproveLoginRequests(approveLoginRequests, userId: nil)
}
/// Sets the clear clipboard value for the active account.
///
/// - Parameter clearClipboardValue: The time after which to clear the clipboard.
@ -683,6 +717,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
return appSettingsStore.allowSyncOnRefresh(userId: userId)
}
func getApproveLoginRequests(userId: String?) async throws -> Bool {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.approveLoginRequests(userId: userId)
}
func getAppTheme() async -> AppTheme {
AppTheme(appSettingsStore.appTheme)
}
@ -792,6 +831,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
appSettingsStore.setAllowSyncOnRefresh(allowSyncOnRefresh, userId: userId)
}
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setApproveLoginRequests(approveLoginRequests, userId: userId)
}
func setAppTheme(_ appTheme: AppTheme) async {
appSettingsStore.appTheme = appTheme.value
appThemeSubject.send(appTheme)

View File

@ -269,6 +269,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertFalse(value)
}
/// `getApproveLoginRequests()` returns the approve login requests setting for the active account.
func test_getApproveLoginRequests() async throws {
await subject.addAccount(.fixture())
appSettingsStore.approveLoginRequestsByUserId["1"] = true
let value = try await subject.getApproveLoginRequests()
XCTAssertTrue(value)
}
/// `getClearClipboardValue()` returns the clear clipboard value for the active account.
func test_getClearClipboardValue() async throws {
await subject.addAccount(.fixture())
@ -846,6 +854,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(appSettingsStore.allowSyncOnRefreshes["1"], true)
}
/// `setApproveLoginRequests(_:userId:)` sets the approve login requests setting for a user.
func test_setApproveLoginRequests() async throws {
await subject.addAccount(.fixture())
try await subject.setApproveLoginRequests(true)
XCTAssertEqual(appSettingsStore.approveLoginRequestsByUserId["1"], true)
}
/// `setBiometricAuthenticationEnabled(isEnabled:)` sets biometric unlock preference for the default user.
func test_setBiometricAuthenticationEnabled_default() async throws {
await subject.addAccount(.fixture())

View File

@ -45,6 +45,14 @@ protocol AppSettingsStore: AnyObject {
///
func allowSyncOnRefresh(userId: String) -> Bool
/// Whether the user has decided to allow the device to approve login requests.
///
/// - Parameter userId: The user ID associated with the approve logins setting.
///
/// - Returns: Whether the user has decided to allow the device to approve login requests.
///
func approveLoginRequests(userId: String) -> Bool
/// The system biometric integrity state `Data`, base64 encoded.
///
/// - Parameter userId: The user ID associated with the Biometric Integrity State.
@ -153,6 +161,14 @@ protocol AppSettingsStore: AnyObject {
///
func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool?, userId: String)
/// Sets whether the user has decided to allow the device to approve login requests.
///
/// - Parameters:
/// - approveLoginRequests: Whether the user has decided to allow the device to approve login requests.
/// - userId: The user ID associated with the approve logins setting.
///
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String)
/// Sets a biometric integrity state `Data` as a base64 encoded `String`.
///
/// - Parameters:
@ -391,6 +407,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case allowSyncOnRefresh(userId: String)
case appId
case appLocale
case approveLoginRequests(userId: String)
case appTheme
case biometricAuthEnabled(userId: String)
case biometricIntegrityState(userId: String)
@ -423,6 +440,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "appId"
case .appLocale:
key = "appLocale"
case let .approveLoginRequests(userId):
key = "approvePasswordlessLogins_\(userId)"
case .appTheme:
key = "theme"
case let .biometricAuthEnabled(userId):
@ -522,6 +541,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
fetch(for: .allowSyncOnRefresh(userId: userId))
}
func approveLoginRequests(userId: String) -> Bool {
fetch(for: .approveLoginRequests(userId: userId))
}
func biometricIntegrityState(userId: String) -> String? {
fetch(for: .biometricIntegrityState(userId: userId))
}
@ -586,6 +609,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
store(allowSyncOnRefresh, for: .allowSyncOnRefresh(userId: userId))
}
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String) {
store(approveLoginRequests, for: .approveLoginRequests(userId: userId))
}
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?, for userId: String) {
store(isEnabled, for: .biometricAuthEnabled(userId: userId))
}

View File

@ -88,6 +88,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertNil(userDefaults.string(forKey: "bwPreferencesStorage:appLocale"))
}
/// `approveLoginRequests(userId:)` returns `false` if there isn't a previously stored value.
func test_approveLoginRequests_isInitiallyFalse() {
XCTAssertFalse(subject.approveLoginRequests(userId: "-1"))
}
/// `approveLoginRequests(userId:)` can be used to get the approve login requests setting for a user.
func test_approveLoginRequests_withValue() {
subject.setApproveLoginRequests(true, userId: "1")
subject.setApproveLoginRequests(false, userId: "2")
XCTAssertTrue(subject.approveLoginRequests(userId: "1"))
XCTAssertFalse(subject.approveLoginRequests(userId: "2"))
XCTAssertTrue(userDefaults.bool(forKey: "bwPreferencesStorage:approvePasswordlessLogins_1"))
XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:approvePasswordlessLogins_2"))
}
/// `appTheme` returns `nil` if there isn't a previously stored value.
func test_appTheme_isInitiallyNil() {
XCTAssertNil(subject.appTheme)

View File

@ -7,6 +7,7 @@ class MockAppSettingsStore: AppSettingsStore {
var allowSyncOnRefreshes = [String: Bool]()
var appId: String?
var appLocale: String?
var approveLoginRequestsByUserId = [String: Bool]()
var appTheme: String?
var biometricAuthenticationEnabled = [String: Bool?]()
var biometricIntegrityStates = [String: String?]()
@ -40,6 +41,10 @@ class MockAppSettingsStore: AppSettingsStore {
allowSyncOnRefreshes[userId] ?? false
}
func approveLoginRequests(userId: String) -> Bool {
approveLoginRequestsByUserId[userId] ?? false
}
func clearClipboardValue(userId: String) -> ClearClipboardValue {
clearClipboardValues[userId] ?? .never
}
@ -92,6 +97,10 @@ class MockAppSettingsStore: AppSettingsStore {
allowSyncOnRefreshes[userId] = allowSyncOnRefresh
}
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String) {
approveLoginRequestsByUserId[userId] = approveLoginRequests
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
clearClipboardValues[userId] = clearClipboardValue
}

View File

@ -12,6 +12,7 @@ class MockStateService: StateService {
var accounts: [Account]?
var allowSyncOnRefresh = [String: Bool]()
var appLanguage: LanguageOption = .default
var approveLoginRequestsByUserId = [String: Bool]()
var appTheme: AppTheme?
var biometricsEnabled = [String: Bool]()
var biometricIntegrityStates = [String: String?]()
@ -96,6 +97,11 @@ class MockStateService: StateService {
try getActiveAccount().profile.userId
}
func getApproveLoginRequests(userId: String?) async throws -> Bool {
let userId = try userId ?? getActiveAccount().profile.userId
return approveLoginRequestsByUserId[userId] ?? false
}
func getAppTheme() async -> AppTheme {
appTheme ?? .default
}
@ -187,6 +193,11 @@ class MockStateService: StateService {
self.allowSyncOnRefresh[userId] = allowSyncOnRefresh
}
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
approveLoginRequestsByUserId[userId] = approveLoginRequests
}
func setAppTheme(_ appTheme: AppTheme) async {
self.appTheme = appTheme
}

View File

@ -34,8 +34,11 @@ enum Constants {
/// A default value for the argon parallelism argument in the KDF algorithm.
static let kdfArgonParallelism = 4
/// The number of minutes until a login request expires.
static let loginRequestTimeoutMinutes = 15
/// The maximum number of accounts permitted for a user.
static let maxAcccounts: Int = 5
static let maxAccounts: Int = 5
/// The maximum number of passwords stored in history.
static let maxPasswordsInHistory = 100

View File

@ -42,7 +42,7 @@ struct ProfileSwitcherState: Equatable {
/// The visibility of the add account row.
var showsAddAccount: Bool {
!shouldAlwaysHideAddAccount && accounts.count < Constants.maxAcccounts
!shouldAlwaysHideAddAccount && accounts.count < Constants.maxAccounts
}
// MARK: Initialization

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "pending_login_requests_empty.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -3,7 +3,29 @@
extension Alert {
// MARK: Methods
/// Confirm allowing the device to approve login requests.
///
/// - Parameter action: The action to perform if the user selects yes.
///
/// - Returns: An alert confirming allowing the device to approve login requests.
///
static func confirmApproveLoginRequests(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.approveLoginRequests,
message: Localizations.useThisDeviceToApproveLoginRequestsMadeFromOtherDevices,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
]
)
}
/// Confirm deleting the folder.
///
/// - Parameter action: The action to perform if the user selects yes.
///
/// - Returns: An alert to confirm deleting the folder.
///
static func confirmDeleteFolder(action: @MainActor @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.doYouReallyWantToDelete,
@ -15,6 +37,23 @@ extension Alert {
)
}
/// Confirm denying all the login requests.
///
/// - Parameter action: The action to perform if the user selects yes.
///
/// - Returns: An alert to confirm denying all the login requests.
///
static func confirmDenyingAllRequests(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.areYouSureYouWantToDeclineAllPendingLogInRequests,
message: nil,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
]
)
}
/// Confirm that the user wants to export their vault.
///
/// - Parameters:
@ -27,7 +66,7 @@ extension Alert {
Alert(
title: Localizations.exportVaultConfirmationTitle,
message: encrypted ?
(Localizations.encExportKeyWarning + "\n\n" + Localizations.encExportAccountWarning) :
(Localizations.encExportKeyWarning + .newLine + Localizations.encExportAccountWarning) :
Localizations.exportVaultWarning,
alertActions: [
AlertAction(title: Localizations.exportVault, style: .default) { _ in await action() },

View File

@ -3,6 +3,21 @@ import XCTest
@testable import BitwardenShared
class AlertSettingsTests: BitwardenTestCase {
/// `confirmApproveLoginRequests(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm approving login requests
func test_confirmApproveLoginRequests() {
let subject = Alert.confirmApproveLoginRequests {}
XCTAssertEqual(subject.title, Localizations.approveLoginRequests)
XCTAssertEqual(subject.message, Localizations.useThisDeviceToApproveLoginRequestsMadeFromOtherDevices)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.no)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.yes)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `confirmDeleteFolder(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm deleting a folder.
func test_confirmDeleteFolder() {
@ -14,6 +29,21 @@ class AlertSettingsTests: BitwardenTestCase {
XCTAssertNil(subject.message)
}
/// `confirmDenyingAllRequests(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm denying all login requests
func test_confirmDenyingAllRequests() {
let subject = Alert.confirmDenyingAllRequests {}
XCTAssertEqual(subject.title, Localizations.areYouSureYouWantToDeclineAllPendingLogInRequests)
XCTAssertNil(subject.message)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.no)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.yes)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `confirmExportVault(encrypted:action:)` constructs an `Alert` with the title, message, and Yes and Export vault
/// buttons.
func test_confirmExportVault() {

View File

@ -4,7 +4,7 @@ import Foundation
/// Actions handled by the `AccountSecurityProcessor`.
///
enum AccountSecurityAction {
enum AccountSecurityAction: Equatable {
/// Clears the account fingerprint phrase URL after the web app has been opened.
case clearFingerprintPhraseUrl
@ -17,6 +17,9 @@ enum AccountSecurityAction {
/// The logout button was pressed.
case logout
/// The pending login requests button was tapped.
case pendingLoginRequestsTapped
/// The session timeout action has changed.
case sessionTimeoutActionChanged(SessionTimeoutAction)

View File

@ -6,7 +6,7 @@ enum AccountSecurityEffect: Equatable {
/// The account fingerprint phrase button was tapped.
case accountFingerprintPhrasePressed
/// The view appeared so the initial data should be loaded.
/// Any initial data for the view should be loaded.
case loadData
/// The user's vault was locked.

View File

@ -72,6 +72,8 @@ final class AccountSecurityProcessor: StateProcessor<
coordinator.navigate(to: .deleteAccount)
case .logout:
showLogoutConfirmation()
case .pendingLoginRequestsTapped:
coordinator.navigate(to: .pendingLoginRequests)
case let .sessionTimeoutActionChanged(action):
saveTimeoutActionSetting(action)
case let .sessionTimeoutValueChanged(newValue):
@ -79,7 +81,7 @@ final class AccountSecurityProcessor: StateProcessor<
case let .setCustomSessionTimeoutValue(newValue):
state.customSessionTimeoutValue = newValue
case let .toggleApproveLoginRequestsToggle(isOn):
state.isApproveLoginRequestsToggleOn = isOn
confirmTogglingApproveLoginRequests(isOn)
case let .toggleUnlockWithPINCode(isOn):
toggleUnlockWithPIN(isOn)
case .twoStepLoginPressed:
@ -89,10 +91,29 @@ final class AccountSecurityProcessor: StateProcessor<
// MARK: Private
/// Loads async data to the state.
/// Show an alert to confirm enabling approving login requests.
///
/// - Parameter isOn: Whether or not the toggle value is true or false.
///
private func confirmTogglingApproveLoginRequests(_ isOn: Bool) {
// If the user is attempting to turn the toggle on, show an alert to confirm first.
if isOn {
coordinator.showAlert(.confirmApproveLoginRequests {
await self.toggleApproveLoginRequests(isOn)
})
} else {
Task { await toggleApproveLoginRequests(isOn) }
}
}
/// Load any initial data for the view.
private func loadData() async {
state.biometricUnlockStatus = await loadBiometricUnlockPreference()
do {
state.biometricUnlockStatus = await loadBiometricUnlockPreference()
state.isApproveLoginRequestsToggleOn = try await services.stateService.getApproveLoginRequests()
} catch {
services.errorReporter.log(error: error)
}
}
/// Loads the state of the user's biometric unlock preferences.
@ -151,15 +172,14 @@ final class AccountSecurityProcessor: StateProcessor<
coordinator.navigate(to: .alert(
.displayFingerprintPhraseAlert({
self.state.fingerprintPhraseUrl = ExternalLinksConstants.fingerprintPhrase
}, phrase: phrase))
)
}, phrase: phrase)
))
} catch {
coordinator.navigate(to: .alert(.defaultAlert(title: Localizations.anErrorHasOccurred)))
}
}
/// Shows an alert asking the user to confirm that they want to logout.
///
private func showLogoutConfirmation() {
let alert = Alert.logoutConfirmation {
do {
@ -172,9 +192,7 @@ final class AccountSecurityProcessor: StateProcessor<
coordinator.navigate(to: .alert(alert))
}
/// Shows the two step login alert. If `Yes` is selected, the user will be
/// navigated to the web app.
///
/// Shows the two step login alert. If `Yes` is selected, the user will be navigated to the web app.
private func showTwoStepLoginAlert() {
coordinator.navigate(to: .alert(.twoStepLoginAlert {
self.state.twoStepLoginUrl = self.services.twoStepLoginService.twoStepLoginUrl()
@ -199,6 +217,19 @@ final class AccountSecurityProcessor: StateProcessor<
}
}
/// Update the value of the approve login requests setting in the state and the cached data.
///
/// - Parameter isOn: Whether or not the toggle value is true or false.
///
private func toggleApproveLoginRequests(_ isOn: Bool) async {
do {
try await services.stateService.setApproveLoginRequests(isOn)
state.isApproveLoginRequestsToggleOn = isOn
} catch {
services.errorReporter.log(error: error)
}
}
/// Shows an alert prompting the user to enter their PIN. If set successfully, the toggle will be turned on.
///
/// - Parameter isOn: Whether or not the toggle value is true or false.

View File

@ -54,6 +54,23 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
// MARK: Tests
/// `perform(_:)` with `.loadData` loads the initial data for the view.
func test_perform_loadData() async {
stateService.activeAccount = .fixture()
stateService.approveLoginRequestsByUserId["1"] = true
await subject.perform(.loadData)
XCTAssertTrue(subject.state.isApproveLoginRequestsToggleOn)
}
/// `perform(_:)` with `.loadData` records any errors.
func test_perform_loadData_error() async {
await subject.perform(.loadData)
XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount)
}
/// `perform(_:)` with `.lockVault` locks the user's vault.
func test_perform_lockVault() async {
let account: Account = .fixtureAccountLogin()
@ -175,6 +192,12 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
)
}
/// `.receive(_:)` with `.pendingLoginRequestsTapped` navigates to the pending requests view.
func test_receive_pendingLoginRequestsTapped() {
subject.receive(.pendingLoginRequestsTapped)
XCTAssertEqual(coordinator.routes.last, .pendingLoginRequests)
}
/// `receive(_:)` with `sessionTimeoutActionChanged(:)` presents an alert if `logout` was selected.
/// It then updates the state if `Yes` was tapped on the alert, confirming the user's decision.
func test_receive_sessionTimeoutActionChanged_logout() async throws {
@ -244,16 +267,61 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
XCTAssertEqual(subject.state.customSessionTimeoutValue, 15)
}
/// `receive(_:)` with `.toggleApproveLoginRequestsToggle` updates the state.
func test_receive_toggleApproveLoginRequestsToggle() {
/// `receive(_:)` with `.toggleApproveLoginRequestsToggle` shows a confirmation alert and updates the state.
func test_receive_toggleApproveLoginRequestsToggle() async throws {
stateService.activeAccount = .fixture()
subject.state.isApproveLoginRequestsToggleOn = false
subject.receive(.toggleApproveLoginRequestsToggle(true))
XCTAssertTrue(subject.state.isApproveLoginRequestsToggleOn)
// Confirm enabling the setting on the alert.
let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.last)
await confirmAction.handler?(confirmAction, [])
waitFor(subject.state.isApproveLoginRequestsToggleOn)
XCTAssertEqual(stateService.approveLoginRequestsByUserId["1"], true)
}
/// `receive(_:)` with `.toggleApproveLoginRequestsToggle` records any errors.
func test_receive_toggleApproveLoginRequestsToggle_error() async throws {
subject.state.isApproveLoginRequestsToggleOn = false
subject.receive(.toggleApproveLoginRequestsToggle(true))
// Confirm enabling the setting on the alert.
let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.last)
await confirmAction.handler?(confirmAction, [])
waitFor(!errorReporter.errors.isEmpty)
XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount)
}
/// `receive(_:)` with `.toggleApproveLoginRequestsToggle` updates the state.
func test_receive_toggleApproveLoginRequestsToggle_toggleOff() {
stateService.activeAccount = .fixture()
subject.state.isApproveLoginRequestsToggleOn = true
let task = Task {
subject.receive(.toggleApproveLoginRequestsToggle(false))
}
waitFor(!subject.state.isApproveLoginRequestsToggleOn)
task.cancel()
XCTAssertEqual(stateService.approveLoginRequestsByUserId["1"], false)
}
/// `receive(_:)` with `.toggleUnlockWithPINCode` updates the state when submit has been pressed.
func test_receive_toggleUnlockWithPINCode() async throws {
func test_receive_toggleUnlockWithPINCode_toggleOff() async throws {
subject.state.isUnlockWithPINCodeOn = true
subject.receive(.toggleUnlockWithPINCode(false))
XCTAssertFalse(subject.state.isUnlockWithPINCodeOn)
XCTAssertTrue(coordinator.routes.isEmpty)
}
/// `receive(_:)` with `.toggleUnlockWithPINCode` displays an alert and updates the state when submit has been
/// pressed.
func test_receive_toggleUnlockWithPINCode_toggleOn() async throws {
subject.state.isUnlockWithPINCodeOn = false
subject.receive(.toggleUnlockWithPINCode(true))
@ -375,4 +443,4 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
XCTAssertEqual(subject.state.twoStepLoginUrl, URL.example)
}
}
} // swiftlint:disable:this file_length

View File

@ -61,8 +61,10 @@ struct AccountSecurityView: View {
SettingsListItem(
Localizations.pendingLogInRequests,
hasDivider: false
) {}
.cornerRadius(10)
) {
store.send(.pendingLoginRequestsTapped)
}
.cornerRadius(10)
}
}
}
@ -208,13 +210,11 @@ struct AccountSecurityView: View {
// MARK: - Previews
#if DEBUG
struct AccountSecurityView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
AccountSecurityView(
store: Store(processor: StateProcessor(state: AccountSecurityState()))
)
}
#Preview {
NavigationView {
AccountSecurityView(
store: Store(processor: StateProcessor(state: AccountSecurityState()))
)
}
}
#endif

View File

@ -1,4 +1,5 @@
import SnapshotTesting
import ViewInspector
import XCTest
@testable import BitwardenShared
@ -27,6 +28,89 @@ class AccountSecurityViewTests: BitwardenTestCase {
subject = nil
}
// MARK: Tests
/// Tapping the Account fingerprint phrase button dispatches the `.accountFingerprintPhrasePressed` effect.
func test_accountFingerprintPhrase_tap() throws {
let button = try subject.inspect().find(button: Localizations.accountFingerprintPhrase)
let task = Task {
try button.tap()
}
waitFor(processor.effects.last == .accountFingerprintPhrasePressed)
task.cancel()
}
/// The view displays a biometrics toggle.
func test_biometricsToggle() throws {
processor.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: false)
_ = try subject.inspect().find(
toggleWithAccessibilityLabel: Localizations.unlockWith(Localizations.faceID)
)
processor.state.biometricUnlockStatus = .available(.touchID, enabled: true, hasValidIntegrity: true)
_ = try subject.inspect().find(
toggleWithAccessibilityLabel: Localizations.unlockWith(Localizations.touchID)
)
}
/// The view hides the biometrics toggle when appropriate.
func test_biometricsToggle_hidden() throws {
processor.state.biometricUnlockStatus = .notAvailable
XCTAssertNil(
try? subject.inspect().find(
toggleWithAccessibilityLabel: Localizations.unlockWith(Localizations.faceID)
)
)
}
/// Tapping the delete account button dispatches the `.deleteAccountPressed` action.
func test_deleteAccountButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.deleteAccount)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .deleteAccountPressed)
}
/// Tapping the lock now button dispatches the `.lockVault` effect.
func test_lockNowButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.lockNow)
let task = Task {
try button.tap()
}
waitFor(processor.effects.last == .lockVault(userInitiated: true))
task.cancel()
}
/// Tapping the pending login requests button dispatches the `.pendingLoginRequestsTapped` action.
func test_pendingRequestsButton_tap() throws {
processor.state.isApproveLoginRequestsToggleOn = true
let button = try subject.inspect().find(button: Localizations.pendingLogInRequests)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .pendingLoginRequestsTapped)
}
/// Tapping the log out button dispatches the `.logout` action.
func test_logOutButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.logOut)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .logout)
}
/// Tapping the two step login button dispatches the `.logout` action.
func test_twoStepLoginButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.twoStepLogin)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .twoStepLoginPressed)
}
/// Changing the unlock with pin toggle dispatches the `.toggleUnlockWithPINCode(_)` action.
func test_unlockWithPinToggle_changed() throws {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
throw XCTSkip("Unable to run test in iOS 16, keep an eye on ViewInspector to see if it gets updated.")
}
let toggle = try subject.inspect().find(ViewType.Toggle.self)
try toggle.tap()
XCTAssertEqual(processor.dispatchedActions.last, .toggleUnlockWithPINCode(true))
}
// MARK: Snapshots
/// The view renders correctly when biometrics are available.
@ -108,48 +192,4 @@ class AccountSecurityViewTests: BitwardenTestCase {
func test_view_render() {
assertSnapshot(of: subject, as: .defaultPortrait)
}
// MARK: Button taps
/// Tapping the Account fingerprint phrase button dispatches the `.accountFingerprintPhrasePressed` effect.
func test_accountFingerprintPhrase_tap() throws {
let button = try subject.inspect().find(button: Localizations.accountFingerprintPhrase)
let task = Task {
try button.tap()
}
waitFor(processor.effects.last == .accountFingerprintPhrasePressed)
task.cancel()
}
/// The view displays a biometrics toggle.
func test_biometricsToggle() throws {
processor.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: false)
_ = try subject.inspect().find(
toggleWithAccessibilityLabel: Localizations.unlockWith(Localizations.faceID)
)
processor.state.biometricUnlockStatus = .available(.touchID, enabled: true, hasValidIntegrity: true)
_ = try subject.inspect().find(
toggleWithAccessibilityLabel: Localizations.unlockWith(Localizations.touchID)
)
}
/// The view hides the biometrics toggle when appropriate.
func test_biometricsToggle_hidden() throws {
processor.state.biometricUnlockStatus = .notAvailable
XCTAssertNil(
try? subject.inspect().find(
toggleWithAccessibilityLabel: Localizations.unlockWith(Localizations.faceID)
)
)
}
/// Tapping the Lock now button dispatches the `.lockVault` effect.
func test_lockNow_tap() throws {
let button = try subject.inspect().find(button: Localizations.lockNow)
let task = Task {
try button.tap()
}
waitFor(processor.effects.last == .lockVault(userInitiated: true))
task.cancel()
}
}

View File

@ -0,0 +1,16 @@
// MARK: - PendingRequestsAction
/// Actions that can be processed by a `PendingRequestsProcessor`.
enum PendingRequestsAction: Equatable {
/// The decline all requests button was tapped.
case declineAllRequestsTapped
/// Dismiss the sheet.
case dismiss
/// A request was tapped.
case requestTapped(LoginRequest)
/// The toast was shown or hidden.
case toastShown(Toast?)
}

View File

@ -0,0 +1,7 @@
// MARK: - PendingRequestsEffect
/// Effects that can be processed by a `PendingRequestsProcessor`.
enum PendingRequestsEffect: Equatable {
/// Load the pending login requests.
case loadData
}

View File

@ -0,0 +1,105 @@
// MARK: - PendingRequestsProcessor
/// The processor used to manage state and handle actions for the `PendingRequestsView`.
///
final class PendingRequestsProcessor: StateProcessor<
PendingRequestsState,
PendingRequestsAction,
PendingRequestsEffect
> {
// MARK: Types
typealias Services = HasAuthService
& HasErrorReporter
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute>
/// The services used by the processor.
private let services: Services
// MARK: Initialization
/// Initializes a `PendingRequestsProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator used for navigation.
/// - services: The services used by the processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute>,
services: Services,
state: PendingRequestsState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: PendingRequestsEffect) async {
switch effect {
case .loadData:
await loadData()
}
}
override func receive(_ action: PendingRequestsAction) {
switch action {
case .declineAllRequestsTapped:
confirmDenyAllRequests()
case .dismiss:
coordinator.navigate(to: .dismiss, context: self)
case let .requestTapped(request):
// TODO: BIT-807
break
case let .toastShown(newValue):
state.toast = newValue
}
}
// MARK: Private Methods
/// Present an alert to confirm denying all the requests.
private func confirmDenyAllRequests() {
// Present an alert to confirm denying all the requests.
coordinator.showAlert(.confirmDenyingAllRequests { await self.denyAllRequests() })
}
/// Deny all the login requests.
private func denyAllRequests() async {
guard case let .data(requests) = state.loadingState else { return }
do {
// Deny all the requests.
coordinator.showLoadingOverlay(title: Localizations.loading)
// TODO: BIT-441
// Refresh the view.
await loadData()
// Show the success toast.
coordinator.hideLoadingOverlay()
state.toast = Toast(text: Localizations.requestsDeclined)
} catch {
coordinator.showAlert(.networkResponseError(error))
services.errorReporter.log(error: error)
}
}
/// Load the pending login requests to display.
private func loadData() async {
do {
let data = try await services.authService.getPendingLoginRequests()
state.loadingState = .data(data)
} catch {
state.loadingState = .data([])
coordinator.showAlert(.networkResponseError(error))
services.errorReporter.log(error: error)
}
}
}

View File

@ -0,0 +1,120 @@
import XCTest
@testable import BitwardenShared
class PendingRequestsProcessorTests: BitwardenTestCase {
// MARK: Properties
var authService: MockAuthService!
var coordinator: MockCoordinator<SettingsRoute>!
var errorReporter: MockErrorReporter!
var subject: PendingRequestsProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authService = MockAuthService()
coordinator = MockCoordinator<SettingsRoute>()
errorReporter = MockErrorReporter()
subject = PendingRequestsProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
authService: authService,
errorReporter: errorReporter
),
state: PendingRequestsState()
)
}
override func tearDown() {
super.tearDown()
authService = nil
coordinator = nil
errorReporter = nil
subject = nil
}
// MARK: Tests
/// `perform(_:)` with `.loadData` loads the pending requests for the view.
func test_perform_loadData() async {
authService.getPendingLoginRequestsResult = .success([.fixture()])
await subject.perform(.loadData)
XCTAssertTrue(authService.getPendingLoginRequestsCalled)
XCTAssertEqual(subject.state.loadingState, .data([.fixture()]))
}
/// `perform(_:)` with `.loadData` handles any errors from loading the data.
func test_perform_loadData_error() async {
authService.getPendingLoginRequestsResult = .failure(BitwardenTestError.example)
await subject.perform(.loadData)
XCTAssertTrue(authService.getPendingLoginRequestsCalled)
XCTAssertEqual(subject.state.loadingState, .data([]))
XCTAssertEqual(coordinator.alertShown.last, .networkResponseError(BitwardenTestError.example))
XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example)
}
/// `.receive(_:)` with `.declineAllRequestsTapped` shows the confirmation alert
/// and declines all the requests.
func test_receive_declineAllRequestsTapped() async throws {
subject.receive(.declineAllRequestsTapped)
// Confirm on the alert.
let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.last)
await confirmAction.handler?(confirmAction, [])
// Verify the results.
// TODO: BIT-441
// XCTAssertTrue(authService.getPendingLoginRequestsCalled)
// XCTAssertEqual(coordinator.loadingOverlaysShown.last?.title, Localizations.loading)
// XCTAssertFalse(coordinator.isLoadingOverlayShowing)
}
/// `.receive(_:)` with `.declineAllRequestsTapped` shows the confirmation alert
/// and handles any errors from declining all the requests.
func test_receive_declineAllRequestsTapped_error() async throws {
subject.receive(.declineAllRequestsTapped)
// Confirm on the alert.
let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.last)
await confirmAction.handler?(confirmAction, [])
// Verify the results.
// TODO: BIT-441
// XCTAssertTrue(authService.getPendingLoginRequestsCalled)
// XCTAssertEqual(coordinator.loadingOverlaysShown.last?.title, Localizations.loading)
// XCTAssertFalse(coordinator.isLoadingOverlayShowing)
// XCTAssertEqual(coordinator.alertShown.last, .networkResponseError(BitwardenTestError.example))
// XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example)
}
/// `receive(_:)` with `.dismiss` dismisses the view
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `receive(_:)` with `.requestTapped(_)` shows the login request view.
func test_receive_requestTapped() {
subject.receive(.requestTapped(.fixture()))
// TODO: BIT-807
}
/// `receive(_:)` with `.toastShown` updates the state's toast value.
func test_receive_toastShown() {
let toast = Toast(text: "toast!")
subject.receive(.toastShown(toast))
XCTAssertEqual(subject.state.toast, toast)
subject.receive(.toastShown(nil))
XCTAssertNil(subject.state.toast)
}
}

View File

@ -0,0 +1,10 @@
// MARK: - PendingRequestsState
/// The state used to present the `PendingRequestsView`.
struct PendingRequestsState: Equatable {
/// The loading state of the pending requests screen.
var loadingState: LoadingState<[LoginRequest]> = .loading
/// A toast message to show in the view.
var toast: Toast?
}

View File

@ -0,0 +1,189 @@
import SwiftUI
// MARK: - PendingRequestsView
/// A view that shows all the pending login requests and allows the user to approve or deny them.
///
struct PendingRequestsView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<PendingRequestsState, PendingRequestsAction, PendingRequestsEffect>
// MARK: View
var body: some View {
GeometryReader { geometry in
LoadingView(state: store.state.loadingState) { pendingRequests in
if pendingRequests.isEmpty {
empty
} else {
pendingRequestsList(pendingRequests)
}
}
.padding(.vertical, 16)
.frame(minHeight: geometry.size.height)
.scrollView(addVerticalPadding: false)
}
.navigationBar(title: Localizations.pendingLogInRequests, titleDisplayMode: .inline)
.toolbar {
cancelToolbarItem {
store.send(.dismiss)
}
}
.task {
await store.perform(.loadData)
}
.refreshable {
await store.perform(.loadData)
}
.toast(store.binding(
get: \.toast,
send: PendingRequestsAction.toastShown
))
}
// MARK: Private Views
/// The decline all requests button.
private var declineAllRequests: some View {
Button {
store.send(.declineAllRequestsTapped)
} label: {
HStack(spacing: 4) {
Spacer()
Image(decorative: Asset.Images.trash)
.frame(width: 16, height: 16)
Text(Localizations.declineAllRequests)
Spacer()
}
}
.buttonStyle(.secondary())
.accessibilityLabel(Localizations.declineAllRequests)
}
/// The empty view.
private var empty: some View {
VStack(spacing: 20) {
Image(decorative: Asset.Images.pendingLoginRequestsEmpty)
Text(Localizations.noPendingRequests)
.styleGuide(.body)
.foregroundStyle(Asset.Colors.textPrimary.swiftUIColor)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
/// The list of pending requests.
///
/// - Parameter pendingRequests: The pending login requests to display.
///
private func pendingRequestsList(_ pendingRequests: [LoginRequest]) -> some View {
VStack(spacing: 16) {
VStack(spacing: 0) {
ForEach(pendingRequests) { pendingRequest in
pendingRequestRow(pendingRequest, hasDivider: pendingRequest != pendingRequests.last)
}
}
.cornerRadius(10)
declineAllRequests
Spacer()
}
}
/// A pending request row.
private func pendingRequestRow(_ pendingRequest: LoginRequest, hasDivider: Bool) -> some View {
Button {
store.send(.requestTapped(pendingRequest))
} label: {
VStack(spacing: 0) {
VStack(spacing: 0) {
Text(Localizations.fingerprintPhrase)
.styleGuide(.body)
.foregroundStyle(Asset.Colors.textPrimary.swiftUIColor)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
Text(pendingRequest.fingerprintPhrase ?? "")
.styleGuide(.caption2Monospaced)
.foregroundStyle(Asset.Colors.fingerprint.swiftUIColor)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
HStack {
Text(pendingRequest.requestDeviceType)
.styleGuide(.footnote)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
Spacer()
Text(pendingRequest.creationDate.formatted(.dateTime))
.styleGuide(.footnote)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
}
.padding(.top, 4)
}
.padding(16)
.accessibilityElement(children: .combine)
if hasDivider {
Divider().padding(.leading, 16)
}
}
}
.background(Asset.Colors.backgroundTertiary.swiftUIColor)
}
}
// MARK: - Previews
#if DEBUG
#Preview("Empty") {
PendingRequestsView(store: Store(processor: StateProcessor(state: PendingRequestsState(
loadingState: .data([])
))))
}
#Preview("Requests") {
PendingRequestsView(store: Store(processor: StateProcessor(state: PendingRequestsState(
loadingState: .data(
[
LoginRequest(
creationDate: Date(),
fingerprintPhrase: "pineapple-on-pizza-is-the-best",
id: "1",
key: nil,
origin: "",
masterPasswordHash: nil,
publicKey: "",
requestAccessCode: nil,
requestApproved: nil,
requestDeviceType: "iOS",
requestIpAddress: "11-22-333-444",
responseDate: nil
),
LoginRequest(
creationDate: Date(),
fingerprintPhrase: "coconuts-are-underrated",
id: "2",
key: nil,
origin: "",
masterPasswordHash: nil,
publicKey: "",
requestAccessCode: nil,
requestApproved: nil,
requestDeviceType: "iOS",
requestIpAddress: "11-22-333-444",
responseDate: nil
),
]
)
))))
}
#endif

View File

@ -0,0 +1,63 @@
import SnapshotTesting
import XCTest
@testable import BitwardenShared
class PendingRequestsViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<PendingRequestsState, PendingRequestsAction, PendingRequestsEffect>!
var subject: PendingRequestsView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: PendingRequestsState())
let store = Store(processor: processor)
subject = PendingRequestsView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the decline all requests button dispatches the `.declineAllRequests` action.
func test_declineAllRequestsButton_tap() throws {
processor.state.loadingState = .data([.fixture()])
let button = try subject.inspect().find(buttonWithAccessibilityLabel: Localizations.declineAllRequests)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .declineAllRequestsTapped)
}
/// Tapping the cancel button dispatches the `.dismiss` action.
func test_cancelButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.cancel)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .dismiss)
}
// MARK: Snapshots
/// The empty view renders correctly.
func test_snapshot_empty() {
processor.state.loadingState = .data([])
assertSnapshots(of: subject.navStackWrapped, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
/// The view with requests renders correctly.
func test_snapshot_requests() {
processor.state.loadingState = .data([
.fixture(fingerprintPhrase: "pineapple-on-pizza-is-the-best", id: "1"),
.fixture(fingerprintPhrase: "coconuts-are-underrated", id: "2"),
])
assertSnapshots(of: subject.navStackWrapped, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
}

View File

@ -37,6 +37,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
typealias Services = HasAccountAPIService
& HasAuthRepository
& HasAuthService
& HasBiometricsService
& HasClientAuth
& HasErrorReporter
@ -118,6 +119,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
showOtherScreen()
case .passwordAutoFill:
showPasswordAutoFill()
case .pendingLoginRequests:
showPendingLoginRequests()
case let .selectLanguage(currentLanguage: currentLanguage):
showSelectLanguage(currentLanguage: currentLanguage, delegate: context as? SelectLanguageDelegate)
case .settings:
@ -285,6 +288,19 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
stackNavigator.push(viewController, navigationTitle: Localizations.passwordAutofill)
}
/// Shows the pending login requests screen.
///
private func showPendingLoginRequests() {
let processor = PendingRequestsProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: PendingRequestsState()
)
let view = PendingRequestsView(store: Store(processor: processor))
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator.present(navController)
}
/// Shows the select language screen.
///
private func showSelectLanguage(currentLanguage: LanguageOption, delegate: SelectLanguageDelegate?) {

View File

@ -188,6 +188,15 @@ class SettingsCoordinatorTests: BitwardenTestCase {
XCTAssertTrue(action.view is UIHostingController<PasswordAutoFillView>)
}
/// `navigate(to:)` with `.pendingLoginRequests()` presents the pending login requests view.
func test_navigateTo_pendingLoginRequests() throws {
subject.navigate(to: .pendingLoginRequests)
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<PendingRequestsView>)
}
/// `navigate(to:)` with `.selectLanguage()` presents the select language view.
func test_navigateTo_selectLanguage() throws {
subject.navigate(to: .selectLanguage(currentLanguage: .default))

View File

@ -68,6 +68,9 @@ public enum SettingsRoute: Equatable, Hashable {
/// A route to the password auto-fill screen.
case passwordAutoFill
/// A route to the pending login requests view.
case pendingLoginRequests
/// A route to view the select language view.
///
/// - Parameter currentLanguage: The currently selected language option.

View File

@ -575,7 +575,7 @@ class AddEditItemProcessorTests: BitwardenTestCase {
XCTAssertEqual(subject.state.collectionIds, ["2"])
}
/// `receive(_:)` with `.dismiss()` navigates to the `.list` route.
/// `receive(_:)` with `.dismiss()` navigates to the `.dismiss()` route.
func test_receive_dismiss() {
subject.receive(.dismissPressed)

View File

@ -1,8 +1,6 @@
import BitwardenSdk
import Foundation
// swiftlint:disable file_length
// MARK: - ViewItemProcessor
/// A processor that can process `ViewItemAction`s.
@ -432,4 +430,4 @@ extension ViewItemProcessor: MoveToOrganizationProcessorDelegate {
func didMoveCipher(_ cipher: CipherView, to organization: CipherOwner) {
state.toast = Toast(text: Localizations.movedItemToOrg(cipher.name, organization.localizedName))
}
}
} // swiftlint:disable:this file_length