mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-15 14:00:25 -06:00
BIT-1507: Pending login requests view (#358)
Co-authored-by: Matt Czech <matt@livefront.com>
This commit is contained in:
parent
786c4291c1
commit
18f0a9192b
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
52
BitwardenShared/Core/Auth/Models/Response/LoginRequest.swift
Normal file
52
BitwardenShared/Core/Auth/Models/Response/LoginRequest.swift
Normal 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 {}
|
||||
@ -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]
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "pending_login_requests_empty.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -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() },
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?)
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
// MARK: - PendingRequestsEffect
|
||||
|
||||
/// Effects that can be processed by a `PendingRequestsProcessor`.
|
||||
enum PendingRequestsEffect: Equatable {
|
||||
/// Load the pending login requests.
|
||||
case loadData
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
}
|
||||
@ -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
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
@ -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?) {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user