[PM-1528] Email verification feature (#813)

This commit is contained in:
André Bispo 2024-08-12 14:58:28 +01:00 committed by GitHub
parent b9c943d19b
commit bea521e1e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 4108 additions and 97 deletions

View File

@ -51,9 +51,28 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
)
hideSplash()
isStartingUp = false
if let userActivity = connectionOptions.userActivities.first,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL {
appProcessor.handleAppLinks(incomingURL: incomingURL)
}
}
}
func scene(
_ scene: UIScene,
continue userActivity: NSUserActivity
) {
guard let appProcessor = (UIApplication.shared.delegate as? AppDelegateType)?.appProcessor,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL else {
return
}
appProcessor.handleAppLinks(incomingURL: incomingURL)
}
func scene(_ scene: UIScene, openURLContexts urlContexts: Set<UIOpenURLContext>) {
guard let url = urlContexts.first?.url,
let appProcessor = (UIApplication.shared.delegate as? AppDelegateType)?.appProcessor

View File

@ -92,4 +92,25 @@ class SceneDelegateTests: BitwardenTestCase {
subject.sceneWillResignActive(scene)
XCTAssertEqual(subject.splashWindow?.alpha, 1)
}
/// `scene(_:continue)` runs successfully
func test_sceneContinue() {
let appProcessor = AppProcessor(
appModule: appModule,
services: ServiceContainer(errorReporter: MockErrorReporter())
)
(UIApplication.shared.delegate as? TestingAppDelegate)?.appProcessor = appProcessor
let session = TestInstanceFactory.create(UISceneSession.self)
let userActivity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
userActivity.webpageURL = URL(string: "https://example.com")
let scene = TestInstanceFactory.create(UIWindowScene.self, properties: [
"session": session,
])
let options = TestInstanceFactory.create(UIScene.ConnectionOptions.self)
subject.scene(scene, willConnectTo: session, options: options)
subject.scene(scene, continue: userActivity)
XCTAssertEqual(subject.splashWindow?.alpha, 1)
}
}

View File

@ -9,6 +9,9 @@
<string>webcredentials:*.bitwarden.com</string>
<string>webcredentials:*.bitwarden.eu</string>
<string>webcredentials:*.bitwarden.pw</string>
<string>applinks:*.bitwarden.com</string>
<string>applinks:*.bitwarden.eu</string>
<string>applinks:*.bitwarden.pw</string>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>

View File

@ -0,0 +1,104 @@
import Foundation
import Networking
// MARK: - RegisterFinishRequestModel
/// The data to include in the body of a `RegisterFinishRequest`.
///
struct RegisterFinishRequestModel: Equatable {
// MARK: Properties
/// The captcha response used in validating a user for this request.
let captchaResponse: String?
/// The user's email address.
let email: String
/// The token to verify the email address
let emailVerificationToken: String
/// The type of kdf for this request.
var kdf: KdfType?
/// The number of kdf iterations performed in this request.
var kdfIterations: Int?
/// The kdf memory allocated for the computed password hash.
var kdfMemory: Int?
/// The number of threads upon which the kdf iterations are performed.
var kdfParallelism: Int?
/// The master password hash used to authenticate a user.
let masterPasswordHash: String
/// The master password hint.
let masterPasswordHint: String?
/// The user's name.
let name: String?
/// The organization's user ID.
let organizationUserId: String?
/// The token used when making this request.
let token: String?
/// The user symmetric key used for this request.
let userSymmetricKey: String?
/// The user asymmetric keys used for this request.
let userAsymmetricKeys: KeysRequestModel?
// MARK: Initialization
/// Initializes a `RegisterFinishRequestModel`.
///
/// - Parameters:
/// - captchaResponse: The captcha response used in validating a user for this request.
/// - email: The user's email address.
/// - kdfConfig: A model for configuring KDF options.
/// - masterPasswordHash: The master password hash used to authenticate a user.
/// - masterPasswordHint: The master password hint.
/// - name: The user's name.
/// - organizationUserId: The organization's user ID.
/// - token: The token used when making this request.
/// - userSymmetricKey: The key used for this request.
/// - userAsymmetricKeys: The keys used for this request.
///
init(
captchaResponse: String? = nil,
email: String,
emailVerificationToken: String,
kdfConfig: KdfConfig,
masterPasswordHash: String,
masterPasswordHint: String?,
name: String? = nil,
organizationUserId: String? = nil,
token: String? = nil,
userSymmetricKey: String? = nil,
userAsymmetricKeys: KeysRequestModel? = nil
) {
self.captchaResponse = captchaResponse
self.email = email
self.emailVerificationToken = emailVerificationToken
kdf = kdfConfig.kdf
kdfIterations = kdfConfig.kdfIterations
kdfMemory = kdfConfig.kdfMemory
kdfParallelism = kdfConfig.kdfParallelism
kdfMemory = kdfConfig.kdfMemory
self.masterPasswordHash = masterPasswordHash
self.masterPasswordHint = masterPasswordHint
self.name = name
self.organizationUserId = organizationUserId
self.token = token
self.userSymmetricKey = userSymmetricKey
self.userAsymmetricKeys = userAsymmetricKeys
}
}
// MARK: JSONRequestBody
extension RegisterFinishRequestModel: JSONRequestBody {
static let encoder = JSONEncoder()
}

View File

@ -0,0 +1,25 @@
import Foundation
import Networking
// MARK: - StartRegistrationRequestModel
/// The data to include in the body of a `StartRegistrationRequest`.
///
struct StartRegistrationRequestModel: Equatable {
// MARK: Properties
/// The user's email address.
let email: String
/// The user name.
let name: String
/// If the user wants to receive marketing emails.
let receiveMarketingEmails: Bool
}
// MARK: JSONRequestBody
extension StartRegistrationRequestModel: JSONRequestBody {
static let encoder = JSONEncoder()
}

View File

@ -0,0 +1,15 @@
import Foundation
import Networking
// MARK: - RegisterFinishResponseModel
/// The response returned from the API upon creating an account.
///
struct RegisterFinishResponseModel: JSONResponse {
static var decoder = JSONDecoder()
// MARK: Properties
/// The captcha bypass token returned in this response.
var captchaBypassToken: String?
}

View File

@ -0,0 +1,21 @@
import XCTest
@testable import BitwardenShared
// MARK: - RegisterFinishResponseModelTests
class RegisterFinishResponseModelTests: BitwardenTestCase {
/// Tests that a response is initialized correctly.
func test_init() {
let subject = RegisterFinishResponseModel(captchaBypassToken: "captchaBypassToken")
XCTAssertEqual(subject.captchaBypassToken, "captchaBypassToken")
}
/// Tests the successful decoding of a JSON response.
func test_decode_success() throws {
let json = APITestData.createAccountSuccess.data
let decoder = JSONDecoder()
let subject = try decoder.decode(RegisterFinishResponseModel.self, from: json)
XCTAssertEqual(subject.captchaBypassToken, "captchaBypassToken")
}
}

View File

@ -0,0 +1,17 @@
import Foundation
import Networking
// MARK: - StartRegistrationResponseModel
/// The response returned from the API upon sending the verification email.
///
struct StartRegistrationResponseModel: Response {
// MARK: Properties
/// The email verification token.
var token: String?
init(response: HTTPResponse) {
token = String(bytes: response.body, encoding: .utf8)
}
}

View File

@ -0,0 +1,22 @@
import XCTest
@testable import BitwardenShared
@testable import Networking
// MARK: - StartRegistrationResponseModelTests
class StartRegistrationResponseModelTests: BitwardenTestCase {
/// Tests that a response is initialized correctly.
func test_init() {
let subject = StartRegistrationResponseModel(
response: HTTPResponse(
url: URL(string: "https://example.com")!,
statusCode: 200,
headers: [:],
body: "0018A45C4D1DEF81644B54AB7F969B88D65".data(using: .utf8)!,
requestID: UUID()
)
)
XCTAssertEqual(subject.token, "0018A45C4D1DEF81644B54AB7F969B88D65")
}
}

View File

@ -42,6 +42,13 @@ protocol AccountAPIService {
///
func preLogin(email: String) async throws -> PreLoginResponseModel
/// Creates an API call for when the user submits the last step of an account creation form.
///
/// - Parameter body: The body to be included in the request.
/// - Returns: Data returned from the `RegisterFinishRequest`.
///
func registerFinish(body: RegisterFinishRequestModel) async throws -> RegisterFinishResponseModel
/// Requests a one-time password to be sent to the user.
///
func requestOtp() async throws
@ -54,6 +61,12 @@ protocol AccountAPIService {
///
func requestPasswordHint(for email: String) async throws
/// Start user account creation
/// - Parameter requestModel: The request model containing the details needed to start user account creation
/// - Returns: Can return an email verification token
///
func startRegistration(requestModel: StartRegistrationRequestModel) async throws -> StartRegistrationResponseModel
/// Set the account keys.
///
/// - Parameter requestModel: The request model containing the keys to set in the account.
@ -128,6 +141,10 @@ extension APIService: AccountAPIService {
return response
}
func registerFinish(body: RegisterFinishRequestModel) async throws -> RegisterFinishResponseModel {
try await identityService.send(RegisterFinishRequest(body: body))
}
func requestOtp() async throws {
_ = try await apiService.send(RequestOtpRequest())
}
@ -145,6 +162,10 @@ extension APIService: AccountAPIService {
_ = try await apiService.send(SetPasswordRequest(requestModel: requestModel))
}
func startRegistration(requestModel: StartRegistrationRequestModel) async throws -> StartRegistrationResponseModel {
try await identityService.send(StartRegistrationRequest(body: requestModel))
}
func updatePassword(_ requestModel: UpdatePasswordRequestModel) async throws {
_ = try await apiService.send(UpdatePasswordRequest(requestModel: requestModel))
}

View File

@ -169,6 +169,69 @@ class AccountAPIServiceTests: BitwardenTestCase {
XCTAssertNil(response.kdfParallelism)
}
/// `registerFinish(email:masterPasswordHash)` throws an error if the request fails.
func test_registerFinish_httpFailure() async {
client.result = .httpFailure()
await assertAsyncThrows {
_ = try await subject.registerFinish(
body: RegisterFinishRequestModel(
email: "example@email.com",
emailVerificationToken: "thisisanawesometoken",
kdfConfig: KdfConfig(),
masterPasswordHash: "1a2b3c",
masterPasswordHint: "hint",
userSymmetricKey: "key",
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
)
)
}
}
/// `registerFinish(email:masterPasswordHash)` throws a decoding error if the response is not the expected type.
func test_registerFinish_failure() async throws {
let resultData = APITestData(data: Data("this should fail".utf8))
client.result = .httpSuccess(testData: resultData)
await assertAsyncThrows {
_ = try await subject.registerFinish(
body: RegisterFinishRequestModel(
email: "example@email.com",
emailVerificationToken: "thisisanawesometoken",
kdfConfig: KdfConfig(),
masterPasswordHash: "1a2b3c",
masterPasswordHint: "hint",
userSymmetricKey: "key",
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
)
)
}
}
/// `registerFinish(email:masterPasswordHash)` returns the correct value from the API with a successful request.
func test_registerFinish_success() async throws {
let resultData = APITestData.createAccountSuccess
client.result = .httpSuccess(testData: resultData)
let successfulResponse = try await subject.registerFinish(
body: RegisterFinishRequestModel(
email: "example@email.com",
emailVerificationToken: "thisisanawesometoken",
kdfConfig: KdfConfig(),
masterPasswordHash: "1a2b3c",
masterPasswordHint: "hint",
userSymmetricKey: "key",
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
)
)
let request = try XCTUnwrap(client.requests.first)
XCTAssertEqual(request.method, .post)
XCTAssertEqual(request.url.relativePath, "/identity/accounts/register/finish")
XCTAssertEqual(successfulResponse.captchaBypassToken, "captchaBypassToken")
XCTAssertNotNil(request.body)
}
/// `requestOtp()` performs a request to request a one-time password for the user.
func test_requestOtp() async throws {
client.result = .httpSuccess(testData: .emptyResponse)
@ -181,6 +244,27 @@ class AccountAPIServiceTests: BitwardenTestCase {
XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/accounts/request-otp")
}
/// `startRegistration(_:)` performs the request to start the registration process.
func test_startRegistration() async throws {
client.result = .httpSuccess(testData: .startRegistrationSuccess)
let requestModel = StartRegistrationRequestModel(
email: "email@example.com",
name: "name",
receiveMarketingEmails: true
)
_ = try await subject.startRegistration(requestModel: requestModel)
XCTAssertEqual(client.requests.count, 1)
XCTAssertNotNil(client.requests[0].body)
XCTAssertEqual(client.requests[0].method, .post)
XCTAssertEqual(
client.requests[0].url.absoluteString,
"https://example.com/identity/accounts/register/send-verification-email"
)
}
/// `setPassword(_:)` performs the request to set the user's password.
func test_setPassword() async throws {
client.result = .httpSuccess(testData: .emptyResponse)

View File

@ -30,4 +30,14 @@ extension APITestData {
// MARK: Request Password Hint
static let passwordHintFailure = loadFromJsonBundle(resource: "PasswordHintFailure")
// MARK: Start Registration
static let startRegistrationEmailAlreadyExists = loadFromJsonBundle(resource: "StartRegistrationEmailAlreadyExists")
static let startRegistrationEmailExceedsMaxLength = loadFromJsonBundle(
resource: "StartRegistrationEmailExceedsMaxLength"
)
static let startRegistrationInvalidEmailFormat = loadFromJsonBundle(resource: "StartRegistrationInvalidEmailFormat")
static let startRegistrationCaptchaFailure = loadFromJsonBundle(resource: "StartRegistrationCaptchaFailure")
static let startRegistrationSuccess = loadFromBundle(resource: "StartRegistrationSuccess", extension: "txt")
}

View File

@ -0,0 +1,10 @@
{
"message":"The model state is invalid.",
"validationErrors":{
"HCaptcha_SiteKey":["bc38c8a2-5311-4e8c-9dfc-49e99f6df417"]
},
"exceptionMessage":null,
"exceptionStackTrace":null,
"innerExceptionMessage":null,
"object":"error"
}

View File

@ -0,0 +1,11 @@
{
"message":"The model state is invalid.",
"validationErrors":{
"":["Email 'j@a.com' is already taken."]
},
"exceptionMessage":null,
"exceptionStackTrace":null,
"innerExceptionMessage":null,
"object":"error"
}

View File

@ -0,0 +1,10 @@
{
"message":"The model state is invalid.",
"validationErrors":{
"Email":["The field Email must be a string with a maximum length of 256."]
},
"exceptionMessage":null,
"exceptionStackTrace":null,
"innerExceptionMessage":null,
"object":"error"
}

View File

@ -0,0 +1,10 @@
{
"message":"The model state is invalid.",
"validationErrors":{
"Email":["The Email field is not a supported e-mail address format."]
},
"exceptionMessage":null,
"exceptionStackTrace":null,
"innerExceptionMessage":null,
"object":"error"
}

View File

@ -0,0 +1,5 @@
{
"name": "name",
"email": "example@email.com",
"captchaResponse": "captchaResponse"
}

View File

@ -0,0 +1 @@
0018A45C4D1DEF81644B54AB7F969B88D65

View File

@ -0,0 +1,55 @@
import Foundation
import Networking
// MARK: - RegisterFinishRequestError
/// Errors that can occur when sending a `RegisterFinishRequest`.
enum RegisterFinishRequestError: Error, Equatable {
/// Captcha is required when creating an account.
///
/// - Parameter hCaptchaSiteCode: The site code to use when authenticating with hCaptcha.
case captchaRequired(hCaptchaSiteCode: String)
}
// MARK: - RegisterFinishRequest
/// The API request sent when submitting an account creation form.
///
struct RegisterFinishRequest: Request {
typealias Response = RegisterFinishResponseModel
typealias Body = RegisterFinishRequestModel
/// The body of this request.
var body: RegisterFinishRequestModel?
/// The HTTP method for this request.
let method: HTTPMethod = .post
/// The URL path for this request.
var path: String = "/accounts/register/finish"
/// Creates a new `RegisterFinishRequest` instance.
///
/// - Parameter body: The body of the request.
///
init(body: RegisterFinishRequestModel) {
self.body = body
}
// MARK: Methods
func validate(_ response: HTTPResponse) throws {
switch response.statusCode {
case 400 ..< 500:
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
if let siteCode = errorResponse.validationErrors?["HCaptcha_SiteKey"]?.first {
throw RegisterFinishRequestError.captchaRequired(hCaptchaSiteCode: siteCode)
}
throw ServerError.error(errorResponse: errorResponse)
default:
return
}
}
}

View File

@ -0,0 +1,125 @@
import Networking
import XCTest
@testable import BitwardenShared
// MARK: - RegisterFinishRequestTests
class RegisterFinishRequestTests: BitwardenTestCase {
// MARK: Properties
var subject: RegisterFinishRequest!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = RegisterFinishRequest(
body: RegisterFinishRequestModel(
email: "example@email.com",
emailVerificationToken: "thisisanawesometoken",
kdfConfig: KdfConfig(),
masterPasswordHash: "1a2b3c",
masterPasswordHint: "hint",
userSymmetricKey: "key",
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
)
)
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// Validate that the method is correct.
func test_method() {
XCTAssertEqual(subject.method, .post)
}
/// Validate that the path is correct.
func test_path() {
XCTAssertEqual(subject.path, "/accounts/register/finish")
}
/// Validate that the body is not nil.
func test_body() {
XCTAssertNotNil(subject.body)
}
/// `validate(_:)` with a `400` status code and an account already exists error in the response body
/// throws an `.accountAlreadyExists` error.
func test_validate_with400AccountAlreadyExists() throws {
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.createAccountAccountAlreadyExists.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
XCTAssertThrowsError(try subject.validate(response)) { error in
XCTAssertEqual(error as? ServerError, .error(errorResponse: errorResponse))
}
}
/// `validate(_:)` with a `400` status code and captcha error in the response body throws a `.captchaRequired`
/// error.
func test_validate_with400CaptchaError() {
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.createAccountCaptchaFailure.data
)
XCTAssertThrowsError(try subject.validate(response)) { error in
XCTAssertEqual(
error as? RegisterFinishRequestError,
.captchaRequired(hCaptchaSiteCode: "bc38c8a2-5311-4e8c-9dfc-49e99f6df417")
)
}
}
/// `validate(_:)` with a `400` status code and an invalid email format error in the response body
/// throws an `.invalidEmailFormat` error.
func test_validate_with400InvalidEmailFormat() {
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.createAccountInvalidEmailFormat.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
XCTAssertThrowsError(try subject.validate(response)) { error in
XCTAssertEqual(error as? ServerError, .error(errorResponse: errorResponse))
}
}
/// `validate(_:)` with a `400` status code but no captcha error does not throw a validation error.
func test_validate_with400NonCaptchaError() {
let response = HTTPResponse.failure(
statusCode: 400,
body: Data("example data".utf8)
)
XCTAssertNoThrow(try subject.validate(response))
}
/// `validate(_:)` with a valid response does not throw a validation error.
func test_validate_with200() {
let response = HTTPResponse.success(
body: APITestData.createAccountSuccess.data
)
XCTAssertNoThrow(try subject.validate(response))
}
// MARK: Init
/// Validate that the value provided to the init method is correct.
func test_init_body() {
XCTAssertEqual(subject.body?.email, "example@email.com")
}
}

View File

@ -0,0 +1,29 @@
import Foundation
import Networking
// MARK: - StartRegistrationRequest
/// The API request sent when starting the account creation.
///
struct StartRegistrationRequest: Request {
typealias Response = StartRegistrationResponseModel
typealias Body = StartRegistrationRequestModel
/// The body of this request.
var body: StartRegistrationRequestModel?
/// The HTTP method for this request.
let method: HTTPMethod = .post
/// The URL path for this request.
var path: String = "/accounts/register/send-verification-email"
/// Creates a new `StartRegistrationRequest` instance.
///
/// - Parameter body: The body of the request.
///
init(body: StartRegistrationRequestModel) {
self.body = body
}
}

View File

@ -0,0 +1,75 @@
import Networking
import XCTest
@testable import BitwardenShared
// MARK: - StartRegistrationRequestTests
class StartRegistrationRequestTests: BitwardenTestCase {
// MARK: Properties
var subject: StartRegistrationRequest!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = StartRegistrationRequest(
body: StartRegistrationRequestModel(
email: "example@email.com",
name: "key",
receiveMarketingEmails: true
)
)
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// Validate that the method is correct.
func test_method() {
XCTAssertEqual(subject.method, .post)
}
/// Validate that the path is correct.
func test_path() {
XCTAssertEqual(subject.path, "/accounts/register/send-verification-email")
}
/// Validate that the body is not nil.
func test_body() {
XCTAssertNotNil(subject.body)
}
/// `validate(_:)` with a `400` status code but no captcha error does not throw a validation error.
func test_validate_with400() {
let response = HTTPResponse.failure(
statusCode: 400,
body: Data("example data".utf8)
)
XCTAssertNoThrow(try subject.validate(response))
}
/// `validate(_:)` with a valid response does not throw a validation error.
func test_validate_with200() {
let response = HTTPResponse.success(
body: APITestData.startRegistrationSuccess.data
)
XCTAssertNoThrow(try subject.validate(response))
}
// MARK: Init
/// Validate that the value provided to the init method is correct.
func test_init_body() {
XCTAssertEqual(subject.body?.email, "example@email.com")
}
}

View File

@ -10,4 +10,7 @@ enum FeatureFlag: String, Codable {
/// A feature flag for showing the unassigned items banner.
case unassignedItemsBanner = "unassigned-items-banner"
/// Flag to enable/disable email verification during registration
/// This flag introduces a new flow for account creation
case emailVerification = "email-verification"
}

View File

@ -35,4 +35,7 @@ enum ExternalLinksConstants {
/// A markdown link to Bitwarden's terms of service.
static let termsOfService = URL(string: "https://bitwarden.com/terms/")!
/// A markdown link to Bitwarden's markting email preferences.
static let unsubscribeFromMarketingEmails = URL(string: "https://bitwarden.com/email-preferences/")!
}

View File

@ -3,6 +3,15 @@ import OSLog
import SwiftUI
import UIKit
// MARK: - AuthCoordinatorError
/// The errors thrown from a `AuthCoordinator`.
///
enum AuthCoordinatorError: Error {
/// When the received delegate does not have a value.
case delegateIsNil
}
// MARK: - AuthCoordinatorDelegate
/// An object that is signaled when specific circumstances in the auth flow have been encountered.
@ -35,6 +44,7 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
& HasBiometricsRepository
& HasCaptchaService
& HasClientService
& HasConfigService
& HasDeviceAPIService
& HasEnvironmentService
& HasErrorReporter
@ -112,6 +122,8 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
callbackUrlScheme: callbackUrlScheme,
delegate: context as? CaptchaFlowDelegate
)
case let .checkEmail(email):
showCheckEmail(email)
case .complete,
.completeWithNeverUnlockKey:
if stackNavigator?.isPresenting == true {
@ -121,10 +133,35 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
} else {
delegate?.didCompleteAuth()
}
case let .completeRegistration(emailVerificationToken, userEmail):
showCompleteRegistration(
emailVerificationToken: emailVerificationToken,
userEmail: userEmail,
region: nil
)
case let .completeRegistrationFromAppLink(emailVerificationToken, userEmail, fromEmail, region):
// Coming from an AppLink clear the current stack
stackNavigator?.dismiss {
self.showLanding()
self.showCompleteRegistration(
emailVerificationToken: emailVerificationToken,
userEmail: userEmail,
fromEmail: fromEmail,
region: region
)
}
case .createAccount:
showCreateAccount()
case .startRegistration:
showStartRegistration(delegate: context as? StartRegistrationDelegate)
case .dismiss:
stackNavigator?.dismiss()
case .dismissPresented:
stackNavigator?.rootViewController?.presentedViewController?.dismiss(animated: true)
case let .dismissWithAction(onDismiss):
stackNavigator?.dismiss(animated: true, completion: {
onDismiss?.action()
})
case let .duoAuthenticationFlow(authURL):
showDuo2FA(authURL: authURL, delegate: context as? DuoAuthenticationFlowDelegate)
case let .enterpriseSingleSignOn(email):
@ -243,6 +280,22 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
session.start()
}
/// Shows the check email screen.
/// - Parameter email: The user's email.
///
private func showCheckEmail(_ email: String) {
let view = CheckEmailView(
store: Store(
processor: CheckEmailProcessor(
coordinator: asAnyCoordinator(),
state: CheckEmailState(email: email)
)
)
)
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
}
/// Shows the create account screen.
///
private func showCreateAccount() {
@ -259,6 +312,32 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
stackNavigator?.present(navController)
}
/// Shows the complete registration screen.
///
private func showCompleteRegistration(
emailVerificationToken: String,
userEmail: String,
fromEmail: Bool = false,
region: RegionType?
) {
let view = CompleteRegistrationView(
store: Store(
processor: CompleteRegistrationProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: CompleteRegistrationState(
emailVerificationToken: emailVerificationToken,
fromEmail: fromEmail,
region: region,
userEmail: userEmail
)
)
)
)
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
}
/// Shows the Duo 2FA screen.
///
/// - Parameters:
@ -535,6 +614,29 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
session.start()
}
/// Shows the start registration screen.
///
private func showStartRegistration(delegate: StartRegistrationDelegate?) {
guard let delegate else {
services.errorReporter.log(error: AuthCoordinatorError.delegateIsNil)
return
}
let processor = StartRegistrationProcessor(
coordinator: asAnyCoordinator(),
delegate: delegate,
services: services,
state: StartRegistrationState()
)
let view = StartRegistrationView(
store: Store(
processor: processor
)
)
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
}
/// Show the two factor authentication view.
///
/// - Parameters:

View File

@ -86,6 +86,15 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertEqual(stackNavigator.actions.last?.type, .dismissedWithCompletionHandler)
}
/// `navigate(to:)` with `.checkEmail` pushes the check email view onto the stack navigator.
func test_navigate_checkEmail() throws {
subject.navigate(to: .checkEmail(email: "email@example.com"))
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<CheckEmailView>)
}
/// `navigate(to:)` with `.completeWithNeverUnlockKey` notifies the delegate that auth has completed.
func test_navigate_completeWithNeverUnlockKey() {
subject.navigate(to: .completeWithNeverUnlockKey)
@ -101,7 +110,52 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<CreateAccountView>)
}
/// `navigate(to:)` with `.dismiss` dismisses the presented view.
/// `navigate(to:)` with `.completeRegistration` pushes the create account view onto the stack navigator.
func test_navigate_completeRegistration() throws {
subject.navigate(to: .completeRegistration(
emailVerificationToken: "thisisanamazingtoken",
userEmail: "email@example.com"
))
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<CompleteRegistrationView>)
}
/// `navigate(to:)` with `.completeRegistrationFromAppLink` pushes the create account view onto the stack navigator.
func test_navigate_completeRegistrationFromAppLink() throws {
subject.navigate(to: .completeRegistrationFromAppLink(
emailVerificationToken: "thisisanamazingtoken",
userEmail: "email@example.com",
fromEmail: true,
region: .unitedStates
))
let landingAction = try XCTUnwrap(stackNavigator.actions[1])
let completeRegistrationAction = try XCTUnwrap(stackNavigator.actions[2])
let completeRegistrationNavigationController = try XCTUnwrap(
completeRegistrationAction.view as? UINavigationController
)
let lastAction = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertTrue(
completeRegistrationNavigationController.viewControllers.first
is UIHostingController<CompleteRegistrationView>
)
XCTAssertTrue(landingAction.view is LandingView)
XCTAssertEqual(lastAction.type, .dismissedWithCompletionHandler)
}
/// `navigate(to:)` with `.startRegistration` pushes the start registration view onto the stack navigator.
func test_navigate_startRegistration() throws {
subject.navigate(to: .startRegistration, context: MockStartRegistrationDelegate())
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<StartRegistrationView>)
}
/// `navigate(to:)` with `.dismiss` dismisses all presented view.
func test_navigate_dismiss() throws {
subject.navigate(to: .createAccount)
subject.navigate(to: .dismiss)
@ -109,6 +163,23 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertEqual(lastAction.type, .dismissed)
}
/// `navigate(to:)` with `.dismissPresented` dismisses the presented view.
func test_navigate_dismissPresented() throws {
subject.navigate(to: .checkEmail(email: "email@example.com"))
subject.navigate(to: .dismissPresented)
let lastAction = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(lastAction.type, .presented)
}
/// `navigate(to:)` with `.dismissWithAction` dismisses the presented view.
func test_navigate_dismissWithAction() throws {
var didRun = false
subject.navigate(to: .dismissWithAction(DismissAction(action: { didRun = true })))
let lastAction = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(lastAction.type, .dismissedWithCompletionHandler)
XCTAssertTrue(didRun)
}
/// `navigate(to:)` with `.enterpriseSingleSignOn` pushes the enterprise single sign-on view onto the stack
/// navigator.
func test_navigate_enterpriseSingleSignOn() throws {

View File

@ -7,9 +7,33 @@ public enum AuthRoute: Equatable {
/// A route to the captcha screen.
case captcha(url: URL, callbackUrlScheme: String)
/// A route to show the check email screen.
case checkEmail(email: String)
/// Dismisses the auth flow.
case complete
/// A route to complete registration screen.
/// - Parameters:
/// - emailVerificationToken: Token needed to complete registration.
/// - userEmail: The user's email.
///
case completeRegistration(emailVerificationToken: String, userEmail: String)
/// A route to complete registration screen.
/// - Parameters:
/// - emailVerificationToken: Token needed to complete registration.
/// - userEmail: The user's email.
/// - fromEmail: The user opened the app from an email AppLink.
/// - region: Region where the complete registration should happen.
///
case completeRegistrationFromAppLink(
emailVerificationToken: String,
userEmail: String,
fromEmail: Bool,
region: RegionType
)
/// Dismisses the auth flow becuase the vault was unlocked with the never unlock key.
case completeWithNeverUnlockKey
@ -19,6 +43,15 @@ public enum AuthRoute: Equatable {
/// A route that dismisses a presented sheet.
case dismiss
/// A route that dismisses only the presented sheet.
case dismissPresented
/// A route to dismiss the screen currently presented modally.
///
/// - Parameter action: The action to perform on dismiss.
///
case dismissWithAction(_ action: DismissAction? = nil)
/// A route that triggers the duo 2FA flow.
/// Requires that any `context` provided to the coordinator conforms to `DuoAuthenticationFlowDelegate`.
case duoAuthenticationFlow(_ authURL: URL)
@ -47,6 +80,10 @@ public enum AuthRoute: Equatable {
///
case showLoginDecryptionOptions(organizationIdentifier: String)
/// A route to start registration screen.
///
case startRegistration
/// A route to the login with device screen.
///
/// - Parameters:

View File

@ -0,0 +1,26 @@
// MARK: - CompleteRegistrationAction
/// Actions that can be processed by a `CompleteRegistrationProcessor`.
///
enum CompleteRegistrationAction: Equatable {
/// The `CompleteRegistrationView` was dismissed.
case dismiss
/// The user edited the password hint text field.
case passwordHintTextChanged(String)
/// The user edited the master password text field.
case passwordTextChanged(String)
/// The user edited the re-type password text field.
case retypePasswordTextChanged(String)
/// The toast was shown or hidden.
case toastShown(Toast?)
/// An action to toggle the data breach check.
case toggleCheckDataBreaches(Bool)
/// An action to toggle whether passwords in text fields are visible.
case togglePasswordVisibility(Bool)
}

View File

@ -0,0 +1,11 @@
// MARK: - CompleteRegistrationEffect
/// The enumeration of possible effects performed by the `CompleteRegistrationProcessor`.
///
enum CompleteRegistrationEffect: Equatable {
/// The complete registration modal appeared on screen.
case appeared
/// The user pressed `Submit` on the `CompleteRegistrationView`, attempting to create an account.
case completeRegistration
}

View File

@ -0,0 +1,263 @@
import AuthenticationServices
import BitwardenSdk
import Combine
import Foundation
import OSLog
// MARK: - CompleteRegistrationError
/// Enumeration of errors that may occur when completing registration for an account.
///
enum CompleteRegistrationError: Error {
/// The password confirmation is not correct.
case passwordsDontMatch
/// The password field is empty.
case passwordEmpty
/// The password does not meet the minimum length requirement.
case passwordIsTooShort
}
// MARK: - CompleteRegistrationProcessor
/// The processor used to manage state and handle actions for the completing registration screen.
///
class CompleteRegistrationProcessor: StateProcessor<
CompleteRegistrationState,
CompleteRegistrationAction,
CompleteRegistrationEffect
> {
// MARK: Types
typealias Services = HasAccountAPIService
& HasAuthRepository
& HasClientService
& HasEnvironmentService
& HasErrorReporter
& HasStateService
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services used by the processor.
private let services: Services
// MARK: Initialization
/// Creates a new `CompleteRegistrationProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - services: The services used by the processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: CompleteRegistrationState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: CompleteRegistrationEffect) async {
switch effect {
case .appeared:
await setRegion()
await verifyUserEmail()
case .completeRegistration:
await checkPasswordAndCompleteRegistration()
}
}
override func receive(_ action: CompleteRegistrationAction) {
switch action {
case .dismiss:
coordinator.navigate(to: .dismissPresented)
case let .passwordHintTextChanged(text):
state.passwordHintText = text
case let .passwordTextChanged(text):
state.passwordText = text
updatePasswordStrength()
case let .retypePasswordTextChanged(text):
state.retypePasswordText = text
case let .toastShown(toast):
state.toast = toast
case let .toggleCheckDataBreaches(newValue):
state.isCheckDataBreachesToggleOn = newValue
case let .togglePasswordVisibility(newValue):
state.arePasswordsVisible = newValue
}
}
// MARK: Private methods
/// Shows an alert if the user's password has been found in a data breach.
/// Also shows an alert if it hasn't, but the password is still weak.
///
/// - Parameter isWeakPassword: Whether the password is weak.
///
private func checkForBreaches(isWeakPassword: Bool) async {
do {
coordinator.showLoadingOverlay(title: Localizations.creatingAccount)
let breachCount = try await services.accountAPIService.checkDataBreaches(password: state.passwordText)
// If unexposed and strong, complete registration
guard breachCount > 0 || isWeakPassword else {
await completeRegistration()
return
}
// If exposed and/or weak, show alert
coordinator.hideLoadingOverlay()
let alertType = Alert.PasswordStrengthAlertType(isBreached: breachCount > 0, isWeak: isWeakPassword)
coordinator.showAlert(.passwordStrengthAlert(alertType) {
await self.completeRegistration()
})
} catch {
await completeRegistration()
Logger.processor.error("HIBP network request failed: \(error)")
}
}
/// Checks the password strength and conditionally checks the password against data breaches.
///
/// An alert is shown if the password:
/// - Is exposed and weak
/// - is exposed and strong
/// - is unexposed and weak
/// - is unchecked against breaches and weak
///
private func checkPasswordAndCompleteRegistration() async {
if state.isCheckDataBreachesToggleOn {
await checkForBreaches(isWeakPassword: state.isWeakPassword)
} else {
guard !state.isWeakPassword else {
coordinator.showAlert(.passwordStrengthAlert(.weak) {
await self.completeRegistration()
})
return
}
await completeRegistration()
}
}
/// Creates the user's account with their provided credentials.
///
/// - Parameter captchaToken: The token returned when the captcha flow has completed.
///
private func completeRegistration(captchaToken: String? = nil) async {
defer { coordinator.hideLoadingOverlay() }
do {
guard !state.passwordText.isEmpty else { throw CompleteRegistrationError.passwordEmpty }
guard state.passwordText.count >= Constants.minimumPasswordCharacters else {
throw CompleteRegistrationError.passwordIsTooShort
}
guard state.passwordText == state.retypePasswordText else {
throw CompleteRegistrationError.passwordsDontMatch
}
coordinator.showLoadingOverlay(title: Localizations.creatingAccount)
let kdf: Kdf = .pbkdf2(iterations: NonZeroU32(KdfConfig().kdfIterations))
let keys = try await services.clientService.auth().makeRegisterKeys(
email: state.userEmail,
password: state.passwordText,
kdf: kdf
)
let hashedPassword = try await services.clientService.auth().hashPassword(
email: state.userEmail,
password: state.passwordText,
kdfParams: kdf,
purpose: .serverAuthorization
)
_ = try await services.accountAPIService.registerFinish(
body: RegisterFinishRequestModel(
captchaResponse: captchaToken,
email: state.userEmail,
emailVerificationToken: state.emailVerificationToken,
kdfConfig: KdfConfig(),
masterPasswordHash: hashedPassword,
masterPasswordHint: state.passwordHintText,
userSymmetricKey: keys.encryptedUserKey,
userAsymmetricKeys: KeysRequestModel(
encryptedPrivateKey: keys.keys.private,
publicKey: keys.keys.public
)
)
)
coordinator.navigate(to: .dismissWithAction(DismissAction {
self.coordinator.showToast(Localizations.accountSuccessfullyCreated)
}))
} catch let error as CompleteRegistrationError {
showCompleteRegistrationErrorAlert(error)
} catch {
coordinator.showAlert(.networkResponseError(error) {
await self.completeRegistration(captchaToken: captchaToken)
})
}
}
/// Sets the URLs to use.
///
private func setRegion() async {
guard state.region != nil,
let urls = state.region?.defaultURLs else { return }
await services.environmentService.setPreAuthURLs(urls: urls)
}
/// Shows a `CompleteRegistrationError` alert.
///
/// - Parameter error: The error that occurred.
///
private func showCompleteRegistrationErrorAlert(_ error: CompleteRegistrationError) {
switch error {
case .passwordsDontMatch:
coordinator.showAlert(.passwordsDontMatch)
case .passwordEmpty:
coordinator.showAlert(.validationFieldRequired(fieldName: Localizations.masterPassword))
case .passwordIsTooShort:
coordinator.showAlert(.passwordIsTooShort)
}
}
/// Updates state's password strength score based on the user's entered password.
///
private func updatePasswordStrength() {
guard !state.passwordText.isEmpty else {
state.passwordStrengthScore = nil
return
}
Task {
state.passwordStrengthScore = try? await services.authRepository.passwordStrength(
email: state.userEmail,
password: state.passwordText
)
}
}
/// Verify users email using the provided token
///
private func verifyUserEmail() async {
// Hide the loading overlay when exiting this method, in case it hasn't been hidden yet.
defer { coordinator.hideLoadingOverlay() }
if state.fromEmail {
state.toast = Toast(text: Localizations.emailVerified)
}
}
}

View File

@ -0,0 +1,566 @@
import AuthenticationServices
import Networking
import XCTest
@testable import BitwardenShared
// MARK: - CompleteRegistrationProcessorTests
// swiftlint:disable:next type_body_length
class CompleteRegistrationProcessorTests: BitwardenTestCase {
// MARK: Properties
var authRepository: MockAuthRepository!
var client: MockHTTPClient!
var clientAuth: MockClientAuth!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var environmentService: MockEnvironmentService!
var errorReporter: MockErrorReporter!
var subject: CompleteRegistrationProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authRepository = MockAuthRepository()
client = MockHTTPClient()
clientAuth = MockClientAuth()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
environmentService = MockEnvironmentService()
errorReporter = MockErrorReporter()
subject = CompleteRegistrationProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
authRepository: authRepository,
clientService: MockClientService(auth: clientAuth),
environmentService: environmentService,
errorReporter: errorReporter,
httpClient: client
),
state: CompleteRegistrationState(
emailVerificationToken: "emailVerificationToken",
userEmail: "example@email.com"
)
)
}
override func tearDown() {
super.tearDown()
authRepository = nil
clientAuth = nil
client = nil
coordinator = nil
errorReporter = nil
subject = nil
}
// MARK: Tests
/// `perform(.appeared)` with EU region in state.
func test_perform_appeared_setRegion_europe() async {
subject.state.region = .europe
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .europe)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultEU)
}
/// `perform(.appeared)` with nil region in state.
func test_perform_appeared_setRegion_return() async {
subject.state.region = nil
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, nil)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, nil)
}
/// `perform(.appeared)` verify user email show toast.
func test_perform_appeared_verifyuseremail_toast() async {
subject.state.fromEmail = true
await subject.perform(.appeared)
XCTAssertEqual(subject.state.toast?.text, Localizations.emailVerified)
}
/// `perform(.appeared)` verify user email show no toast.
func test_perform_appeared_verifyuseremail_notoast() async {
subject.state.fromEmail = false
await subject.perform(.appeared)
XCTAssertNil(subject.state.toast)
}
/// `perform(.appeared)` verify user email hide loading.
func test_perform_appeared_verifyuseremail_hideloading() async {
coordinator.isLoadingOverlayShowing = true
subject.state.fromEmail = true
await subject.perform(.appeared)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertNotNil(coordinator.loadingOverlaysShown)
XCTAssertEqual(subject.state.toast?.text, Localizations.emailVerified)
}
/// `perform(_:)` with `.completeRegistration` will still make the `CompleteRegistrationRequest` when the HIBP
/// network request fails.
func test_perform_checkPasswordAndCompleteRegistration_failure() async throws {
subject.state = .fixture(isCheckDataBreachesToggleOn: true)
client.results = [.httpFailure(URLError(.timedOut) as Error), .httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
var dismissAction: DismissAction?
if case let .dismissWithAction(onDismiss) = coordinator.routes.last {
dismissAction = onDismiss
}
XCTAssertNotNil(dismissAction)
dismissAction?.action()
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/e6b6a"))
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
/// is weak and exposed. This also tests that the correct alert is presented.
/// Additionally, this tests that tapping Yes on the alert creates the account.
func test_perform_checkPasswordAndCompleteRegistration_exposedWeak_yesTapped() async throws {
subject.state = .fixture(isCheckDataBreachesToggleOn: true, passwordStrengthScore: 1)
client.results = [.httpSuccess(testData: .hibpLeakedPasswords), .httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/e6b6a"))
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertEqual(coordinator.alertShown.last, Alert(
title: Localizations.weakAndExposedMasterPassword,
message: Localizations.weakPasswordIdentifiedAndFoundInADataBreachAlertDescription,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in },
]
))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
/// is strong and exposed. This also tests that the correct alert is presented.
/// Additionally, this tests that tapping Yes on the alert creates the account.
func test_perform_checkPasswordAndCompleteRegistration_exposedStrong_yesTapped() async throws {
subject.state = .fixture(isCheckDataBreachesToggleOn: true, passwordStrengthScore: 3)
client.results = [.httpSuccess(testData: .hibpLeakedPasswords), .httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/e6b6a"))
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertEqual(coordinator.alertShown.last, Alert(
title: Localizations.exposedMasterPassword,
message: Localizations.passwordFoundInADataBreachAlertDescription,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in },
]
))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
/// is weak and unchecked against breaches. This also tests that the correct alert is presented.
/// Additionally, this tests that tapping Yes on the alert creates the account.
func test_perform_checkPasswordAndCompleteRegistration_uncheckedWeak_yesTapped() async throws {
subject.state = .fixture(
isCheckDataBreachesToggleOn: false,
passwordText: "unexposed123",
passwordStrengthScore: 2,
retypePasswordText: "unexposed123"
)
client.results = [.httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(client.requests[0].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertEqual(coordinator.alertShown.last, Alert(
title: Localizations.weakMasterPassword,
message: Localizations.weakPasswordIdentifiedUseAStrongPasswordToProtectYourAccount,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in },
]
))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
/// is weak and unexposed. This also tests that the correct alert is presented.
/// Additionally, this tests that tapping Yes on the alert creates the account.
func test_perform_checkPasswordAndCompleteRegistration_unexposedWeak_yesTapped() async throws {
subject.state = .fixture(
isCheckDataBreachesToggleOn: true,
passwordText: "unexposed123",
passwordStrengthScore: 2,
retypePasswordText: "unexposed123"
)
client.results = [.httpSuccess(testData: .hibpLeakedPasswords), .httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/6bf92"))
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertEqual(coordinator.alertShown.last, Alert(
title: Localizations.weakMasterPassword,
message: Localizations.weakPasswordIdentifiedUseAStrongPasswordToProtectYourAccount,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in },
]
))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the email has already been taken.
func test_perform_completeRegistration_accountAlreadyExists() async {
subject.state = .fixture()
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.createAccountAccountAlreadyExists.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
client.result = .httpFailure(
ServerError.error(errorResponse: errorResponse)
)
await subject.perform(.completeRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
coordinator.alertShown.last,
.defaultAlert(
title: Localizations.anErrorHasOccurred,
message: "Email 'j@a.com' is already taken."
)
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password field is empty.
func test_perform_completeRegistration_emptyPassword() async {
subject.state = .fixture(passwordText: "", retypePasswordText: "")
client.result = .httpSuccess(testData: .createAccountRequest)
await subject.perform(.completeRegistration)
XCTAssertEqual(client.requests.count, 0)
XCTAssertEqual(coordinator.alertShown.last, .validationFieldRequired(fieldName: "Master password"))
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password hint is too long.
func test_perform_completeRegistration_hintTooLong() async {
subject.state = .fixture(passwordHintText: """
ajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajaj
ajajajajajajajajajajajajajajajajajajajajajajajajajajajajajsjajajajajaj
""")
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.createAccountHintTooLong.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
client.result = .httpFailure(
ServerError.error(errorResponse: errorResponse)
)
await subject.perform(.completeRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
coordinator.alertShown.last,
.defaultAlert(
title: Localizations.anErrorHasOccurred,
message: "The field MasterPasswordHint must be a string with a maximum length of 50."
)
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the email is in an invalid format.
func test_perform_completeRegistration_invalidEmailFormat() async {
subject.state = .fixture(userEmail: "∫@ø.com")
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.createAccountInvalidEmailFormat.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
client.result = .httpFailure(
ServerError.error(errorResponse: errorResponse)
)
await subject.perform(.completeRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
coordinator.alertShown.last,
.defaultAlert(
title: Localizations.anErrorHasOccurred,
message: "The Email field is not a supported e-mail address format."
)
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.completeRegistration` presents an alert when there is no internet connection.
/// When the user taps `Try again`, the create account request is made again.
func test_perform_completeRegistration_noInternetConnection() async throws {
subject.state = .fixture()
let urlError = URLError(.notConnectedToInternet) as Error
client.results = [.httpFailure(urlError), .httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, Alert.networkResponseError(urlError) {
await self.subject.perform(.completeRegistration)
})
try await alert.tapAction(title: Localizations.tryAgain)
var dismissAction: DismissAction?
if case let .dismissWithAction(onDismiss) = coordinator.routes.last {
dismissAction = onDismiss
}
XCTAssertNotNil(dismissAction)
dismissAction?.action()
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(client.requests[0].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when password confirmation is incorrect.
func test_perform_completeRegistration_passwordsDontMatch() async {
subject.state = .fixture(passwordText: "123456789012", retypePasswordText: "123456789000")
client.result = .httpSuccess(testData: .createAccountRequest)
await subject.perform(.completeRegistration)
XCTAssertEqual(client.requests.count, 0)
XCTAssertEqual(coordinator.alertShown.last, .passwordsDontMatch)
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the password isn't long enough.
func test_perform_completeRegistration_passwordsTooShort() async {
subject.state = .fixture(passwordText: "123", retypePasswordText: "123")
client.result = .httpSuccess(testData: .createAccountRequest)
await subject.perform(.completeRegistration)
XCTAssertEqual(client.requests.count, 0)
XCTAssertEqual(coordinator.alertShown.last, .passwordIsTooShort)
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
}
/// `perform(_:)` with `.completeRegistration` presents an alert when the request times out.
/// When the user taps `Try again`, the create account request is made again.
func test_perform_completeRegistration_timeout() async throws {
subject.state = .fixture()
let urlError = URLError(.timedOut) as Error
client.results = [.httpFailure(urlError), .httpSuccess(testData: .createAccountRequest)]
await subject.perform(.completeRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.message, urlError.localizedDescription)
try await alert.tapAction(title: Localizations.tryAgain)
var dismissAction: DismissAction?
if case let .dismissWithAction(onDismiss) = coordinator.routes.last {
dismissAction = onDismiss
}
XCTAssertNotNil(dismissAction)
dismissAction?.action()
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(client.requests[0].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `receive(_:)` with `.dismiss` dismisses the view.
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
}
/// `receive(_:)` with `.passwordHintTextChanged(_:)` updates the state to reflect the change.
func test_receive_passwordHintTextChanged() {
subject.state.passwordHintText = ""
XCTAssertTrue(subject.state.passwordHintText.isEmpty)
subject.receive(.passwordHintTextChanged("updated hint"))
XCTAssertTrue(subject.state.passwordHintText == "updated hint")
}
/// `receive(_:)` with `.passwordTextChanged(_:)` updates the state to reflect the change.
func test_receive_passwordTextChanged() {
subject.state.passwordText = ""
XCTAssertTrue(subject.state.passwordText.isEmpty)
subject.receive(.passwordTextChanged("updated password"))
XCTAssertTrue(subject.state.passwordText == "updated password")
}
/// `receive(_:)` with `.passwordTextChanged(_:)` updates the password strength score based on
/// the entered password.
func test_receive_passwordTextChanged_updatesPasswordStrength() {
subject.state.userEmail = "user@bitwarden.com"
subject.receive(.passwordTextChanged(""))
XCTAssertNil(subject.state.passwordStrengthScore)
XCTAssertNil(authRepository.passwordStrengthPassword)
authRepository.passwordStrengthResult = 0
subject.receive(.passwordTextChanged("T"))
waitFor(subject.state.passwordStrengthScore == 0)
XCTAssertEqual(subject.state.passwordStrengthScore, 0)
XCTAssertEqual(authRepository.passwordStrengthEmail, "user@bitwarden.com")
XCTAssertEqual(authRepository.passwordStrengthPassword, "T")
authRepository.passwordStrengthResult = 4
subject.receive(.passwordTextChanged("TestPassword1234567890!@#"))
waitFor(subject.state.passwordStrengthScore == 4)
XCTAssertEqual(subject.state.passwordStrengthScore, 4)
XCTAssertEqual(authRepository.passwordStrengthEmail, "user@bitwarden.com")
XCTAssertEqual(authRepository.passwordStrengthPassword, "TestPassword1234567890!@#")
}
/// `receive(_:)` with `.retypePasswordTextChanged(_:)` updates the state to reflect the change.
func test_receive_retypePasswordTextChanged() {
subject.state.retypePasswordText = ""
XCTAssertTrue(subject.state.retypePasswordText.isEmpty)
subject.receive(.retypePasswordTextChanged("updated re-type"))
XCTAssertTrue(subject.state.retypePasswordText == "updated re-type")
}
/// `receive(_:)` with `.toggleCheckDataBreaches(_:)` updates the state to reflect the change.
func test_receive_toggleCheckDataBreaches() {
subject.receive(.toggleCheckDataBreaches(false))
XCTAssertFalse(subject.state.isCheckDataBreachesToggleOn)
subject.receive(.toggleCheckDataBreaches(true))
XCTAssertTrue(subject.state.isCheckDataBreachesToggleOn)
subject.receive(.toggleCheckDataBreaches(true))
XCTAssertTrue(subject.state.isCheckDataBreachesToggleOn)
}
/// `receive(_:)` with `.togglePasswordVisibility(_:)` updates the state to reflect the change.
func test_receive_togglePasswordVisibility() {
subject.state.arePasswordsVisible = false
subject.receive(.togglePasswordVisibility(true))
XCTAssertTrue(subject.state.arePasswordsVisible)
subject.receive(.togglePasswordVisibility(true))
XCTAssertTrue(subject.state.arePasswordsVisible)
subject.receive(.togglePasswordVisibility(false))
XCTAssertFalse(subject.state.arePasswordsVisible)
}
/// `receive(_:)` with `.showToast` show toast.
func test_receive_showToast() {
subject.receive(.toastShown(Toast(text: "example")))
XCTAssertEqual(subject.state.toast?.text, "example")
}
// swiftlint:disable:next file_length
}

View File

@ -0,0 +1,61 @@
import SwiftUI
// MARK: - CompleteRegistrationState
/// An object that defines the current state of a `CompleteRegistrationView`.
///
struct CompleteRegistrationState: Equatable {
// MARK: Properties
/// Whether passwords are visible in the view's text fields.
var arePasswordsVisible: Bool = false
/// Token needed to complete registration
var emailVerificationToken: String
/// Whether the user came from email AppLink
var fromEmail: Bool = false
/// Whether the check for data breaches toggle is on.
var isCheckDataBreachesToggleOn: Bool = true
/// Whether the password is considered weak.
var isWeakPassword: Bool {
guard let passwordStrengthScore else { return false }
return passwordStrengthScore < 3
}
/// The text in the password hint text field.
var passwordHintText: String = ""
/// The text in the master password text field.
var passwordText: String = ""
/// A scoring metric that represents the strength of the entered password. The score ranges from
/// 0-4 (weak to strong password).
var passwordStrengthScore: UInt8?
/// The password visibility icon used in the view's text fields.
var passwordVisibleIcon: ImageAsset {
arePasswordsVisible ? Asset.Images.hidden : Asset.Images.visible
}
/// The region where the account should be created
var region: RegionType?
/// The text in the re-type password text field.
var retypePasswordText: String = ""
/// The email of the user that is creating the account.
var userEmail: String
/// A toast message to show in the view.
var toast: Toast?
// MARK: Computed Properties
/// Text with user email in bold
var headelineTextBoldEmail: String {
Localizations.finishCreatingYourAccountForXBySettingAPassword("**\(userEmail)**")
}
}

View File

@ -0,0 +1,172 @@
import SwiftUI
// MARK: - CompleteRegistrationView
/// A view that allows the user to create an account.
///
struct CompleteRegistrationView: View {
// MARK: Properties
/// The store used to render the view.
@ObservedObject var store: Store<CompleteRegistrationState, CompleteRegistrationAction, CompleteRegistrationEffect>
// MARK: View
var body: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 0) {
Text(LocalizedStringKey(store.state.headelineTextBoldEmail))
.tint(Asset.Colors.textPrimary.swiftUIColor)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.multilineTextAlignment(.leading)
.styleGuide(.callout)
.padding(.bottom, 16)
passwordField
.padding(.bottom, 8)
passwordStrengthIndicator
}
retypePassword
passwordHint
VStack(spacing: 24) {
checkBreachesToggle
.padding(.top, 8)
createAccountButton
}
}
.animation(.default, value: store.state.passwordStrengthScore)
.navigationBar(title: Localizations.setPassword, titleDisplayMode: .inline)
.scrollView()
.toolbar {
cancelToolbarItem {
store.send(.dismiss)
}
}
.task {
await store.perform(.appeared)
}
.toast(store.binding(
get: \.toast,
send: CompleteRegistrationAction.toastShown
))
}
// MARK: Private views
/// A toggle to check the user's password for security breaches.
private var checkBreachesToggle: some View {
Toggle(isOn: store.binding(
get: \.isCheckDataBreachesToggleOn,
send: CompleteRegistrationAction.toggleCheckDataBreaches
)) {
Text(Localizations.checkKnownDataBreachesForThisPassword)
.styleGuide(.footnote)
}
.accessibilityIdentifier("CheckExposedMasterPasswordToggle")
.toggleStyle(.bitwarden)
.id(ViewIdentifier.CompleteRegistration.checkBreaches)
}
/// The text fields for the user's email and password.
private var passwordField: some View {
BitwardenTextField(
title: Localizations.masterPassword,
text: store.binding(
get: \.passwordText,
send: CompleteRegistrationAction.passwordTextChanged
),
accessibilityIdentifier: "MasterPasswordEntry",
passwordVisibilityAccessibilityId: "PasswordVisibilityToggle",
isPasswordVisible: store.binding(
get: \.arePasswordsVisible,
send: CompleteRegistrationAction.togglePasswordVisibility
)
)
.textFieldConfiguration(.password)
}
/// The master password hint.
private var passwordHint: some View {
VStack(alignment: .leading, spacing: 8) {
BitwardenTextField(
title: Localizations.masterPasswordHint,
text: store.binding(
get: \.passwordHintText,
send: CompleteRegistrationAction.passwordHintTextChanged
),
accessibilityIdentifier: "MasterPasswordHintLabel"
)
Text(Localizations.masterPasswordHintDescription)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
.styleGuide(.footnote)
}
}
/// The password strength indicator.
private var passwordStrengthIndicator: some View {
VStack(alignment: .leading, spacing: 0) {
Group {
Text(Localizations.important + ": ").bold() +
Text(Localizations.yourMasterPasswordCannotBeRecoveredIfYouForgetItXCharactersMinimum(
Constants.minimumPasswordCharacters)
)
}
.styleGuide(.footnote)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
.padding(.bottom, 16)
PasswordStrengthIndicator(
passwordStrengthScore: store.state.passwordStrengthScore
)
}
}
/// The text field for re-typing the master password.
private var retypePassword: some View {
BitwardenTextField(
title: Localizations.retypeMasterPassword,
text: store.binding(
get: \.retypePasswordText,
send: CompleteRegistrationAction.retypePasswordTextChanged
),
accessibilityIdentifier: "ConfirmMasterPasswordEntry",
passwordVisibilityAccessibilityId: "ConfirmPasswordVisibilityToggle",
isPasswordVisible: store.binding(
get: \.arePasswordsVisible,
send: CompleteRegistrationAction.togglePasswordVisibility
)
)
.textFieldConfiguration(.password)
}
/// The button pressed when the user attempts to create the account.
private var createAccountButton: some View {
Button {
Task {
await store.perform(.completeRegistration)
}
} label: {
Text(Localizations.createAccount)
}
.accessibilityIdentifier("CreateAccountButton")
.buttonStyle(.primary())
}
}
// MARK: Previews
#if DEBUG
#Preview {
CompleteRegistrationView(store: Store(processor: StateProcessor(
state: CompleteRegistrationState(
emailVerificationToken: "emailVerificationToken",
userEmail: "example@bitwarden.com"
))))
}
#endif

View File

@ -0,0 +1,133 @@
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest
@testable import BitwardenShared
// MARK: - CompleteRegistrationViewTests
class CompleteRegistrationViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<CompleteRegistrationState, CompleteRegistrationAction, CompleteRegistrationEffect>!
var subject: CompleteRegistrationView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: CompleteRegistrationState(
emailVerificationToken: "emailVerificationToken",
userEmail: "email@example.com"
))
let store = Store(processor: processor)
subject = CompleteRegistrationView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// 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)
}
/// Tapping the check for security breaches toggle dispatches the `.toggleCheckDataBreaches()` action.
func test_checkBreachesToggle_tap() 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(viewWithId: ViewIdentifier.CompleteRegistration.checkBreaches).toggle()
try toggle.tap()
XCTAssertEqual(processor.dispatchedActions.last, .toggleCheckDataBreaches(true))
}
/// Updating the text field dispatches the `.passwordHintTextChanged()` action.
func test_hintField_updateValue() throws {
let textfield = try subject.inspect().find(viewWithId: Localizations.masterPasswordHint).textField()
try textfield.setInput("text")
XCTAssertEqual(processor.dispatchedActions.last, .passwordHintTextChanged("text"))
}
/// Updating the text field dispatches the `.passwordTextChanged()` action.
func test_masterPasswordField_updateValue() throws {
processor.state.arePasswordsVisible = true
let textfield = try subject.inspect().find(viewWithId: Localizations.masterPassword).textField()
try textfield.setInput("text")
XCTAssertEqual(processor.dispatchedActions.last, .passwordTextChanged("text"))
}
/// Tapping the password visibility icon changes whether or not passwords are visible.
func test_passwordVisibility_tap() throws {
processor.state.arePasswordsVisible = false
let visibilityIcon = try subject.inspect().find(
viewWithAccessibilityLabel: Localizations.passwordIsNotVisibleTapToShow
).button()
try visibilityIcon.tap()
XCTAssertEqual(processor.dispatchedActions.last, .togglePasswordVisibility(true))
}
/// Updating the text field dispatches the `.retypePasswordTextChanged()` action.
func test_retypePasswordField_updateValue() throws {
processor.state.arePasswordsVisible = true
let textfield = try subject.inspect().find(viewWithId: Localizations.retypeMasterPassword).textField()
try textfield.setInput("text")
XCTAssertEqual(processor.dispatchedActions.last, .retypePasswordTextChanged("text"))
}
/// Tapping the submit button performs the `.CompleteRegistration` effect.
func test_createAccountButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.createAccount)
try button.tap()
waitFor(!processor.effects.isEmpty)
XCTAssertEqual(processor.effects.last, .completeRegistration)
}
// MARK: Snapshots
/// Tests the view renders correctly when the text fields are all empty.
func test_snapshot_empty() {
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
/// Tests the view renders correctly when text fields are hidden.
func test_snapshot_textFields_hidden() throws {
processor.state.arePasswordsVisible = false
processor.state.userEmail = "email@example.com"
processor.state.passwordText = "12345"
processor.state.retypePasswordText = "12345"
processor.state.passwordHintText = "wink wink"
processor.state.passwordStrengthScore = 0
assertSnapshot(matching: subject, as: .defaultPortrait)
}
/// Tests the view renders correctly when the text fields are all populated.
func test_snapshot_textFields_populated() throws {
processor.state.arePasswordsVisible = true
processor.state.userEmail = "email@example.com"
processor.state.passwordText = "12345"
processor.state.retypePasswordText = "12345"
processor.state.passwordHintText = "wink wink"
processor.state.passwordStrengthScore = 0
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
/// Tests the view renders correctly when the toggles are on.
func test_snapshot_toggles_on() throws {
processor.state.isCheckDataBreachesToggleOn = true
assertSnapshot(matching: subject, as: .defaultPortrait)
}
}

View File

@ -0,0 +1,28 @@
import BitwardenSdk
import Foundation
@testable import BitwardenShared
extension CompleteRegistrationState {
static func fixture(
arePasswordsVisible: Bool = false,
emailVerificationToken: String = "emailVerificationToken",
isCheckDataBreachesToggleOn: Bool = false,
passwordHintText: String = "",
passwordText: String = "password1234",
passwordStrengthScore: UInt8 = 3,
userEmail: String = "email@example.com",
retypePasswordText: String = "password1234"
) -> Self {
CompleteRegistrationState(
arePasswordsVisible: arePasswordsVisible,
emailVerificationToken: emailVerificationToken,
isCheckDataBreachesToggleOn: isCheckDataBreachesToggleOn,
passwordHintText: passwordHintText,
passwordText: passwordText,
passwordStrengthScore: passwordStrengthScore,
retypePasswordText: retypePasswordText,
userEmail: userEmail
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File

@ -11,9 +11,6 @@ enum LandingAction: Equatable {
/// A forwarded profile switcher action.
case profileSwitcher(ProfileSwitcherAction)
/// The region button was pressed.
case regionPressed
/// The value for the remember me toggle was changed.
case rememberMeChanged(Bool)

View File

@ -10,4 +10,7 @@ enum LandingEffect: Equatable {
/// A Profile Switcher Effect.
case profileSwitcher(ProfileSwitcherEffect)
/// The region button was pressed.
case regionPressed
}

View File

@ -10,6 +10,7 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
typealias Services = HasAppSettingsStore
& HasAuthRepository
& HasConfigService
& HasEnvironmentService
& HasErrorReporter
& HasStateService
@ -22,6 +23,13 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
/// The services required by this processor.
private let services: Services
/// Helper class with region specific functions
private lazy var regionHelper = RegionHelper(
coordinator: coordinator,
delegate: self,
stateService: services.stateService
)
// MARK: Initialization
/// Creates a new `LandingProcessor`.
@ -43,7 +51,6 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
var state = state
state.email = rememberedEmail ?? ""
state.isRememberMeOn = rememberedEmail != nil
super.init(state: state)
}
@ -52,26 +59,33 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
override func perform(_ effect: LandingEffect) async {
switch effect {
case .appeared:
await loadRegion()
await regionHelper.loadRegion()
await refreshProfileState()
case .continuePressed:
updateRememberedEmail()
await validateEmailAndContinue()
case let .profileSwitcher(profileEffect):
await handleProfileSwitcherEffect(profileEffect)
case .regionPressed:
await regionHelper.presentRegionSelectorAlert(
title: Localizations.loggingInOn,
currentRegion: state.region
)
}
}
override func receive(_ action: LandingAction) {
switch action {
case .createAccountPressed:
coordinator.navigate(to: .createAccount)
if state.emailVerificationFeatureFlag {
coordinator.navigate(to: .startRegistration, context: self)
} else {
coordinator.navigate(to: .createAccount)
}
case let .emailChanged(newValue):
state.email = newValue
case let .profileSwitcher(profileAction):
handleProfileSwitcherAction(profileAction)
case .regionPressed:
presentRegionSelectionAlert()
case let .rememberMeChanged(newValue):
state.isRememberMeOn = newValue
if !newValue {
@ -84,21 +98,14 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
// MARK: Private Methods
/// Sets the region to the last used region.
/// Sets the feature flag value to be used.
///
private func loadRegion() async {
guard let urls = await services.stateService.getPreAuthEnvironmentUrls() else {
await setRegion(.unitedStates, urls: .defaultUS)
return
}
if urls.base == EnvironmentUrlData.defaultUS.base {
await setRegion(.unitedStates, urls: urls)
} else if urls.base == EnvironmentUrlData.defaultEU.base {
await setRegion(.europe, urls: urls)
} else {
await setRegion(.selfHosted, urls: urls)
}
private func loadFeatureFlag() async {
state.emailVerificationFeatureFlag = await services.configService.getFeatureFlag(
FeatureFlag.emailVerification,
defaultValue: false,
forceRefresh: true
)
}
/// Validate the currently entered email address and navigate to the login screen.
@ -120,43 +127,6 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
coordinator.navigate(to: .login(username: email))
}
/// Builds an alert for region selection and navigates to the alert.
///
private func presentRegionSelectionAlert() {
let actions = RegionType.allCases.map { region in
AlertAction(title: region.baseUrlDescription, style: .default) { [weak self] _ in
if let urls = region.defaultURLs {
await self?.setRegion(region, urls: urls)
} else {
self?.coordinator.navigate(
to: .selfHosted(currentRegion: self?.state.region ?? .unitedStates),
context: self
)
}
}
}
let cancelAction = AlertAction(title: Localizations.cancel, style: .cancel)
let alert = Alert(
title: Localizations.loggingInOn,
message: nil,
preferredStyle: .actionSheet,
alertActions: actions + [cancelAction]
)
coordinator.showAlert(alert)
}
/// Sets the region and the URLs to use.
///
/// - Parameters:
/// - region: The region to use.
/// - urls: The URLs that the app should use for the region.
///
private func setRegion(_ region: RegionType, urls: EnvironmentUrlData) async {
guard !urls.isEmpty else { return }
await services.environmentService.setPreAuthURLs(urls: urls)
state.region = region
}
/// Updates the value of `rememberedEmail` in the app settings store with the `email` value in `state`.
///
private func updateRememberedEmail() {
@ -222,7 +192,36 @@ extension LandingProcessor: ProfileSwitcherHandler {
extension LandingProcessor: SelfHostedProcessorDelegate {
func didSaveEnvironment(urls: EnvironmentUrlData) async {
await setRegion(.selfHosted, urls: urls)
await setRegion(.selfHosted, urls)
state.toast = Toast(text: Localizations.environmentSaved)
await regionHelper.loadRegion()
}
}
// MARK: - StartRegistrationDelegate
extension LandingProcessor: StartRegistrationDelegate {
func didChangeRegion() async {
await regionHelper.loadRegion()
}
}
// MARK: - RegionDelegate
extension LandingProcessor: RegionDelegate {
/// Sets the region and the URLs to use.
///
/// - Parameters:
/// - region: The region to use.
/// - urls: The URLs that the app should use for the region.
///
func setRegion(_ region: RegionType, _ urls: EnvironmentUrlData) async {
guard !urls.isEmpty else { return }
await services.environmentService.setPreAuthURLs(urls: urls)
state.region = region
// After setting a new region, feature flags need to be reloaded
Task {
await loadFeatureFlag()
}
}
}

View File

@ -9,6 +9,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
var appSettingsStore: MockAppSettingsStore!
var authRepository: MockAuthRepository!
var configService: MockConfigService!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var environmentService: MockEnvironmentService!
var errorReporter: MockErrorReporter!
@ -22,6 +23,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
appSettingsStore = MockAppSettingsStore()
authRepository = MockAuthRepository()
configService = MockConfigService()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
environmentService = MockEnvironmentService()
errorReporter = MockErrorReporter()
@ -31,6 +33,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
let services = ServiceContainer.withMocks(
appSettingsStore: appSettingsStore,
authRepository: authRepository,
configService: configService,
environmentService: environmentService,
errorReporter: errorReporter,
stateService: stateService
@ -47,6 +50,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
authRepository = nil
appSettingsStore = nil
configService = nil
coordinator = nil
environmentService = nil
errorReporter = nil
@ -56,9 +60,22 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
// MARK: Tests
/// `didChangeRegion(urls:)` update URLs when they change on the StartRegistration modal
func test_didChangeRegion() async {
stateService.preAuthEnvironmentUrls = EnvironmentUrlData(base: .example)
subject.state.region = .unitedStates
await subject.didChangeRegion()
XCTAssertEqual(subject.state.region, .selfHosted)
XCTAssertEqual(
environmentService.setPreAuthEnvironmentUrlsData,
EnvironmentUrlData(base: .example)
)
}
/// `didSaveEnvironment(urls:)` with URLs sets the region to self-hosted and sets the URLs in
/// the environment.
func test_didSaveEnvironment() async {
stateService.preAuthEnvironmentUrls = EnvironmentUrlData(base: .example)
subject.state.region = .unitedStates
await subject.didSaveEnvironment(urls: EnvironmentUrlData(base: .example))
XCTAssertEqual(subject.state.region, .selfHosted)
@ -71,6 +88,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `didSaveEnvironment(urls:)` with empty URLs doesn't change the region or the environment URLs.
func test_didSaveEnvironment_empty() async {
stateService.preAuthEnvironmentUrls = EnvironmentUrlData()
subject.state.region = .unitedStates
await subject.didSaveEnvironment(urls: EnvironmentUrlData())
XCTAssertEqual(subject.state.region, .unitedStates)
@ -112,6 +130,42 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
}
/// `perform(.appeared)` with feature flag for .emailVerification set to true
func test_perform_appeared_loadsFeatureFlag_true() async {
configService.featureFlagsBool[.emailVerification] = true
subject.state.emailVerificationFeatureFlag = false
let task = Task {
await subject.perform(.appeared)
}
await task.value
XCTAssertTrue(subject.state.emailVerificationFeatureFlag)
}
/// `perform(.appeared)` with feature flag for .emailVerification set to false
func test_perform_appeared_loadsFeatureFlag_false() async {
configService.featureFlagsBool[.emailVerification] = false
subject.state.emailVerificationFeatureFlag = true
let task = Task {
await subject.perform(.appeared)
}
await task.value
XCTAssertFalse(subject.state.emailVerificationFeatureFlag)
}
/// `perform(.appeared)` with feature flag defaulting to false
func test_perform_appeared_loadsFeatureFlag_nil() async {
configService.featureFlagsBool[.emailVerification] = nil
subject.state.emailVerificationFeatureFlag = true
let task = Task {
await subject.perform(.appeared)
}
await task.value
XCTAssertFalse(subject.state.emailVerificationFeatureFlag)
}
/// `perform(.appeared)` with an active account and accounts should yield a profile switcher state.
func test_perform_appeared_profiles_single_active() async {
let profile = ProfileSwitcherItem.fixture()
@ -358,12 +412,20 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertTrue(subject.state.isRememberMeOn)
}
/// `receive(_:)` with `.createAccountPressed` navigates to the create account screen.
func test_receive_createAccountPressed() {
/// `receive(_:)` with `.createAccountPressed` navigates to the create account screen if feature flag is `false`.
func test_receive_createAccountPressed_ff_false() {
subject.state.emailVerificationFeatureFlag = false
subject.receive(.createAccountPressed)
XCTAssertEqual(coordinator.routes.last, .createAccount)
}
/// `receive(_:)` with `.createAccountPressed` navigates to the start registration screen if feature flag is `true`.
func test_receive_createAccountPressed_ff_true() {
subject.state.emailVerificationFeatureFlag = true
subject.receive(.createAccountPressed)
XCTAssertEqual(coordinator.routes.last, .startRegistration)
}
/// `receive(_:)` with `.emailChanged` and an empty value updates the state to reflect the changes.
func test_receive_emailChanged_empty() {
subject.state.email = "email@example.com"
@ -382,11 +444,11 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertTrue(subject.state.isContinueButtonEnabled)
}
/// `receive(_:)` with `.regionPressed` navigates to the region selection screen.
func test_receive_regionPressed() async throws {
subject.receive(.regionPressed)
/// `perform(_:)` with `.regionPressed` navigates to the region selection screen.
func test_perform_regionPressed() async throws {
await subject.perform(.regionPressed)
let alert = try XCTUnwrap(coordinator.alertShown.last)
var alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.loggingInOn)
XCTAssertNil(alert.message)
XCTAssertEqual(alert.alertActions.count, 4)
@ -395,10 +457,14 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
try await alert.tapAction(title: "bitwarden.com")
XCTAssertEqual(subject.state.region, .unitedStates)
await subject.perform(.regionPressed)
alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu")
try await alert.tapAction(title: "bitwarden.eu")
XCTAssertEqual(subject.state.region, .europe)
await subject.perform(.regionPressed)
alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
try await alert.tapAction(title: Localizations.selfHosted)
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe))

View File

@ -25,6 +25,9 @@ struct LandingState: Equatable {
/// A toast message to show in the view.
var toast: Toast?
/// Flag to use email verification or not
var emailVerificationFeatureFlag: Bool = true
// MARK: Initialization
/// Creates a new `LandingState`.

View File

@ -94,24 +94,12 @@ struct LandingView: View {
Task { await store.perform(.continuePressed) }
}
Button {
store.send(.regionPressed)
} label: {
HStack(spacing: 4) {
Group {
Text("\(Localizations.loggingInOn): ")
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
+ Text(store.state.region.baseUrlDescription)
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
}
.styleGuide(.subheadline)
Image(decorative: Asset.Images.downAngle)
.scaledFrame(width: 12, height: 12)
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
}
RegionSelector(
selectorLabel: Localizations.loggingInOn,
regionName: store.state.region.baseUrlDescription
) {
await store.perform(.regionPressed)
}
.accessibilityIdentifier("RegionSelectorDropdown")
Toggle(Localizations.rememberMe, isOn: store.binding(
get: { $0.isRememberMeOn },

View File

@ -72,7 +72,8 @@ class LandingViewTests: BitwardenTestCase {
button: "\(Localizations.loggingInOn): \(RegionType.unitedStates.baseUrlDescription)"
)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .regionPressed)
waitFor(processor.effects.last != nil)
XCTAssertEqual(processor.effects.last, .regionPressed)
}
/// Tapping the remember me toggle dispatches the `.rememberMeChanged` action.

View File

@ -57,7 +57,7 @@ final class SelfHostedProcessor: StateProcessor<SelfHostedState, SelfHostedActio
case let .apiUrlChanged(url):
state.apiServerUrl = url
case .dismiss:
coordinator.navigate(to: .dismiss)
coordinator.navigate(to: .dismissPresented)
case let .iconsUrlChanged(url):
state.iconsServerUrl = url
case let .identityUrlChanged(url):
@ -107,6 +107,6 @@ final class SelfHostedProcessor: StateProcessor<SelfHostedState, SelfHostedActio
webVault: URL(string: state.webVaultServerUrl)?.sanitized
)
await delegate?.didSaveEnvironment(urls: urls)
coordinator.navigate(to: .dismiss)
coordinator.navigate(to: .dismissPresented)
}
}

View File

@ -43,7 +43,7 @@ class SelfHostedProcessorTests: BitwardenTestCase {
delegate.savedUrls,
EnvironmentUrlData(base: URL(string: "https://vault.bitwarden.com")!)
)
XCTAssertEqual(coordinator.routes.last, .dismiss)
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
}
/// `perform(_:)` with `.saveEnvironment` notifies the delegate that the user saved the URLs.
@ -68,7 +68,7 @@ class SelfHostedProcessorTests: BitwardenTestCase {
webVault: URL(string: "https://vault.bitwarden.com")!
)
)
XCTAssertEqual(coordinator.routes.last, .dismiss)
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
}
/// `perform(_:)` with `.saveEnvironment` displays an alert if any of the URLs are invalid.
@ -98,7 +98,7 @@ class SelfHostedProcessorTests: BitwardenTestCase {
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismiss)
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
}
/// Receiving `.iconsUrlChanged` updates the state.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -0,0 +1,13 @@
// MARK: - CheckEmailAction
/// Actions that can be processed by a `CheckEmailProcessor`.
enum CheckEmailAction: Equatable {
/// The dismiss button was tapped.
case dismissTapped
/// The go back button was tapped
case goBackTapped
/// The log in button was tapped
case logInTapped
}

View File

@ -0,0 +1,38 @@
// MARK: - CheckEmailProcessor
/// The processor used to manage state and handle actions for the check email screen.
///
class CheckEmailProcessor: StateProcessor<CheckEmailState, CheckEmailAction, Void> {
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
// MARK: Initialization
/// Creates a new `CheckEmailProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
state: CheckEmailState
) {
self.coordinator = coordinator
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: CheckEmailAction) {
switch action {
case .dismissTapped,
.logInTapped:
coordinator.navigate(to: .dismiss)
case .goBackTapped:
coordinator.navigate(to: .dismissPresented)
}
}
}

View File

@ -0,0 +1,51 @@
import Networking
import XCTest
@testable import BitwardenShared
// MARK: - CheckEmailProcessorTests
class CheckEmailProcessorTests: BitwardenTestCase {
// MARK: Properties
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var subject: CheckEmailProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
subject = CheckEmailProcessor(
coordinator: coordinator.asAnyCoordinator(),
state: CheckEmailState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
subject = nil
}
// MARK: Tests
/// `receive(_:)` with `.dismiss` dismisses the view.
func test_receive_dismissTapped() {
subject.receive(.dismissTapped)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `receive(_:)` with `.goBackTapped` dismisses the view.
func test_receive_goBackTapped() {
subject.receive(.goBackTapped)
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
}
/// `receive(_:)` with `.logInTapped` dismisses the view.
func test_receive_logInTapped() {
subject.receive(.logInTapped)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
// MARK: - CheckEmailState
/// An object that defines the current state of a `CheckEmailView`.
///
struct CheckEmailState: Equatable {
// MARK: Properties
/// User's email address.
var email: String = ""
// MARK: Computed Properties
/// Text with user email in bold
var headelineTextBoldEmail: String {
Localizations.followTheInstructionsInTheEmailSentToXToContinueCreatingYourAccount("**\(email)**")
}
}

View File

@ -0,0 +1,86 @@
import SwiftUI
// MARK: - CheckEmailView
/// A view that allows the user to create an account.
///
struct CheckEmailView: View {
// MARK: Properties
/// An object used to open urls from this view.
@Environment(\.openURL) private var openURL
/// The store used to render the view.
@ObservedObject var store: Store<CheckEmailState, CheckEmailAction, Void>
// MARK: View
var body: some View {
VStack(spacing: 16) {
VStack(alignment: .center, spacing: 0) {
Image(decorative: Asset.Images.checkEmail)
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
Text(Localizations.checkYourEmail)
.styleGuide(.title2)
.multilineTextAlignment(.center)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.frame(maxWidth: .infinity)
.padding([.bottom, .horizontal], 8)
Text(LocalizedStringKey(store.state.headelineTextBoldEmail))
.styleGuide(.headline)
.multilineTextAlignment(.center)
.padding(.bottom, 20)
.padding(.horizontal, 34)
.tint(Asset.Colors.textPrimary.swiftUIColor)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
Button(Localizations.openEmailApp) {
openURL(URL(string: "message://")!)
}
.accessibilityIdentifier("OpenEmailAppButton")
.padding(.horizontal, 50)
.padding(.bottom, 32)
.buttonStyle(.primary())
Text(LocalizedStringKey(Localizations.noEmailGoBackToEditYourEmailAddress))
.styleGuide(.subheadline)
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.padding([.horizontal, .bottom], 32)
.environment(\.openURL, OpenURLAction { _ in
store.send(.goBackTapped)
return .handled
})
Text(LocalizedStringKey(Localizations.orLogInYouMayAlreadyHaveAnAccount))
.styleGuide(.subheadline)
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.padding(.horizontal, 32)
.environment(\.openURL, OpenURLAction { _ in
store.send(.logInTapped)
return .handled
})
}
}
.navigationBar(title: Localizations.createAccount, titleDisplayMode: .inline)
.scrollView()
.toolbar {
cancelToolbarItem {
store.send(.dismissTapped)
}
}
}
}
// MARK: Previews
#if DEBUG
#Preview {
CheckEmailView(store: Store(processor: StateProcessor(state: CheckEmailState(email: "email@example.com"))))
}
#endif

View File

@ -0,0 +1,46 @@
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest
@testable import BitwardenShared
// MARK: - CheckEmailViewTests
class CheckEmailViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<CheckEmailState, CheckEmailAction, Void>!
var subject: CheckEmailView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: CheckEmailState(email: "example@email.com"))
let store = Store(processor: processor)
subject = CheckEmailView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// 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, .dismissTapped)
}
// MARK: Snapshots
/// Tests the view renders correctly.
func test_snapshot_empty() {
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@ -0,0 +1,20 @@
import BitwardenSdk
import Foundation
@testable import BitwardenShared
extension StartRegistrationState {
static func fixture(
emailText: String = "example@email.com",
isReceiveMarketingToggleOn: Bool = true,
nameText: String = "name",
region: RegionType = .unitedStates
) -> Self {
StartRegistrationState(
emailText: emailText,
isReceiveMarketingToggleOn: isReceiveMarketingToggleOn,
nameText: nameText,
region: region
)
}
}

View File

@ -0,0 +1,20 @@
// MARK: - StartRegistrationAction
/// Actions that can be processed by a `StartRegistrationProcessor`.
///
enum StartRegistrationAction: Equatable {
/// The `StartRegistrationView` was dismissed.
case dismiss
/// The user edited the email text field.
case emailTextChanged(String)
/// The user edited the name text field.
case nameTextChanged(String)
/// The toast was shown or hidden.
case toastShown(Toast?)
/// An action to toggle the terms and privacy agreement.
case toggleReceiveMarketing(Bool)
}

View File

@ -0,0 +1,14 @@
// MARK: - StartRegistrationEffect
/// The enumeration of possible effects performed by the `StartRegistrationProcessor`.
///
enum StartRegistrationEffect: Equatable {
/// The start registration appeared on screen.
case appeared
/// The user tapped the region selector button
case regionTapped
/// The user pressed `Submit` on the `StartRegistrationView`, attempting to create an account.
case startRegistration
}

View File

@ -0,0 +1,209 @@
import AuthenticationServices
import BitwardenSdk
import Combine
import Foundation
import OSLog
// MARK: - StartRegistrationDelegate
/// A delegate of `StartRegistrationProcessor` that is notified when the user changes region.
///
protocol StartRegistrationDelegate: AnyObject {
/// Called when the user changes regions.
///
func didChangeRegion() async
}
// MARK: - StartRegistrationError
/// Enumeration of errors that may occur when start registration.
///
enum StartRegistrationError: Error {
/// The terms of service and privacy policy have not been acknowledged.
case acceptPoliciesError
/// The email field is empty.
case emailEmpty
/// The email is invalid.
case invalidEmail
}
// MARK: - StartRegistrationProcessor
/// The processor used to manage state and handle actions for the start registration screen.
///
class StartRegistrationProcessor: StateProcessor<
StartRegistrationState,
StartRegistrationAction,
StartRegistrationEffect
> {
// MARK: Types
typealias Services = HasAccountAPIService
& HasAuthRepository
& HasClientService
& HasEnvironmentService
& HasErrorReporter
& HasStateService
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services used by the processor.
private let services: Services
/// The delegate for the processor that is notified when the user closes the registration view.
private weak var delegate: StartRegistrationDelegate?
/// Helper class with region specific functions
private lazy var regionHelper = RegionHelper(
coordinator: coordinator,
delegate: self,
stateService: services.stateService
)
// MARK: Initialization
/// Creates a new `StartRegistrationProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - services: The services used by the processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
delegate: StartRegistrationDelegate?,
services: Services,
state: StartRegistrationState
) {
self.coordinator = coordinator
self.delegate = delegate
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: StartRegistrationEffect) async {
switch effect {
case .appeared:
await regionHelper.loadRegion()
state.isReceiveMarketingToggleOn = state.region == .unitedStates
case .regionTapped:
await regionHelper.presentRegionSelectorAlert(
title: Localizations.creatingOn,
currentRegion: state.region
)
case .startRegistration:
await startRegistration()
}
}
override func receive(_ action: StartRegistrationAction) {
switch action {
case let .emailTextChanged(text):
state.emailText = text
case .dismiss:
coordinator.navigate(to: .dismiss)
case let .nameTextChanged(text):
state.nameText = text
case let .toggleReceiveMarketing(newValue):
state.isReceiveMarketingToggleOn = newValue
case let .toastShown(toast):
state.toast = toast
}
}
// MARK: Private methods
/// Initiates the first step of the registration.
///
private func startRegistration() async {
// Hide the loading overlay when exiting this method, in case it hasn't been hidden yet.
defer { coordinator.hideLoadingOverlay() }
do {
let email = state.emailText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let name = state.nameText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !email.isEmpty else {
throw StartRegistrationError.emailEmpty
}
guard email.isValidEmail else {
throw StartRegistrationError.invalidEmail
}
coordinator.showLoadingOverlay(title: Localizations.creatingAccount)
let result = try await services.accountAPIService.startRegistration(
requestModel: StartRegistrationRequestModel(
email: email,
name: name,
receiveMarketingEmails: state.isReceiveMarketingToggleOn
)
)
if let token = result.token,
!token.isEmpty {
coordinator.navigate(to: .completeRegistration(
emailVerificationToken: token,
userEmail: state.emailText
))
} else {
coordinator.navigate(to: .checkEmail(email: state.emailText))
}
} catch let error as StartRegistrationError {
showStartRegistrationErrorAlert(error)
} catch {
coordinator.showAlert(.networkResponseError(error) {
await self.startRegistration()
})
}
}
/// Shows a `StartRegistrationError` alert.
///
/// - Parameter error: The error that occurred.
///
private func showStartRegistrationErrorAlert(_ error: StartRegistrationError) {
switch error {
case .acceptPoliciesError:
coordinator.showAlert(.acceptPoliciesAlert())
case .emailEmpty:
coordinator.showAlert(.validationFieldRequired(fieldName: Localizations.email))
case .invalidEmail:
coordinator.showAlert(.invalidEmail)
}
}
}
// MARK: - SelfHostedProcessorDelegate
extension StartRegistrationProcessor: SelfHostedProcessorDelegate {
func didSaveEnvironment(urls: EnvironmentUrlData) async {
await setRegion(.selfHosted, urls)
state.toast = Toast(text: Localizations.environmentSaved)
}
}
// MARK: - RegionDelegate
extension StartRegistrationProcessor: RegionDelegate {
/// Sets the region and the URLs to use.
///
/// - Parameters:
/// - region: The region to use.
/// - urls: The URLs that the app should use for the region.
///
func setRegion(_ region: RegionType, _ urls: EnvironmentUrlData) async {
guard !urls.isEmpty else { return }
await services.environmentService.setPreAuthURLs(urls: urls)
state.region = region
state.showReceiveMarketingToggle = state.region != .selfHosted
await delegate?.didChangeRegion()
}
}

View File

@ -0,0 +1,466 @@
import AuthenticationServices
import Networking
import XCTest
@testable import BitwardenShared
// MARK: - StartRegistrationProcessorTests
class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var authRepository: MockAuthRepository!
var captchaService: MockCaptchaService!
var client: MockHTTPClient!
var clientAuth: MockClientAuth!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
var subject: StartRegistrationProcessor!
var delegate: MockStartRegistrationDelegate!
var stateService: MockStateService!
var environmentService: MockEnvironmentService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authRepository = MockAuthRepository()
captchaService = MockCaptchaService()
client = MockHTTPClient()
clientAuth = MockClientAuth()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
environmentService = MockEnvironmentService()
errorReporter = MockErrorReporter()
stateService = MockStateService()
subject = StartRegistrationProcessor(
coordinator: coordinator.asAnyCoordinator(),
delegate: delegate,
services: ServiceContainer.withMocks(
authRepository: authRepository,
captchaService: captchaService,
clientService: MockClientService(auth: clientAuth),
environmentService: environmentService,
errorReporter: errorReporter,
httpClient: client,
stateService: stateService
),
state: StartRegistrationState()
)
}
override func tearDown() {
super.tearDown()
authRepository = nil
captchaService = nil
clientAuth = nil
client = nil
coordinator = nil
environmentService = nil
errorReporter = nil
subject = nil
stateService = nil
}
// MARK: Tests
/// `perform(_:)` with `.regionTapped` navigates to the region selection screen.
func test_perform_regionTapped() async throws {
await subject.perform(.regionTapped)
var alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.creatingOn)
XCTAssertNil(alert.message)
XCTAssertEqual(alert.alertActions.count, 4)
XCTAssertEqual(alert.alertActions[0].title, "bitwarden.com")
try await alert.tapAction(title: "bitwarden.com")
XCTAssertEqual(subject.state.region, .unitedStates)
await subject.perform(.regionTapped)
alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu")
try await alert.tapAction(title: "bitwarden.eu")
XCTAssertEqual(subject.state.region, .europe)
await subject.perform(.regionTapped)
alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
try await alert.tapAction(title: Localizations.selfHosted)
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe))
}
/// `perform(_:)` with `.startRegistration` presents an alert when the email has already been taken.
func test_perform_startRegistration_emailExists() async {
subject.state = .fixture()
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.startRegistrationEmailAlreadyExists.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
client.result = .httpFailure(
ServerError.error(errorResponse: errorResponse)
)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
coordinator.alertShown.last,
.defaultAlert(
title: Localizations.anErrorHasOccurred,
message: "Email 'j@a.com' is already taken."
)
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.startRegistration` presents an alert when the email exceeds the maximum length.
func test_perform_startRegistration_emailExceedsMaxLength() async {
subject.state = .fixture(emailText: """
eyrztwlvxqdksnmcbjgahfpouyqiwubfdzoxhjsrlnvgeatkcpimy\
fqaxhztsowbmdkjlrpnuqvycigfexrvlosqtpnheujawzsdmkbfoy\
cxqpwkzthbnmudxlysgarcejfqvopzrkihwdelbuxyfqnjsgptamcozrvihsl\
nbujrtdosmvhxwyfapzcklqoxbgdvtfieqyuhwajnrpslmcskgzofdqehxcbv\
omjltzafwudqypnisgrkeohycbvxjflaumtwzrdqnpsoiezgyhqbmxdlvnzwa\
htjoekrcispgvyfbuqklszepjwdrantihxfcoygmuslqbajzdfgrkmwbpnouq\
tlsvixechyfjslrdvngiwzqpcotxubamhyekufjrzdwmxihqkfonslbcjgtpu\
voyaezrctudwlskjpvmfqhnxbriyg@example.com
""")
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.startRegistrationEmailExceedsMaxLength.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
client.result = .httpFailure(
ServerError.error(errorResponse: errorResponse)
)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
coordinator.alertShown.last,
.defaultAlert(
title: Localizations.anErrorHasOccurred,
message: "The field Email must be a string with a maximum length of 256."
)
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.startRegistration` presents an alert when the email field is empty.
func test_perform_startRegistration_emptyEmail() async {
subject.state = .fixture(emailText: "")
client.result = .httpSuccess(testData: .startRegistrationSuccess)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 0)
XCTAssertEqual(coordinator.alertShown.last, .validationFieldRequired(fieldName: "Email"))
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
}
/// `perform(_:)` with `.startRegistration` presents an alert when the email is in an invalid format.
func test_perform_startRegistration_invalidEmailFormat() async {
subject.state = .fixture(emailText: "∫@ø.com")
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.startRegistrationInvalidEmailFormat.data
)
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
client.result = .httpFailure(
ServerError.error(errorResponse: errorResponse)
)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
coordinator.alertShown.last,
.defaultAlert(
title: Localizations.anErrorHasOccurred,
message: "The Email field is not a supported e-mail address format."
)
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.startRegistration` presents an alert when there is no internet connection.
/// When the user taps `Try again`, the create account request is made again.
func test_perform_startRegistration_noInternetConnection() async throws {
subject.state = .fixture()
let urlError = URLError(.notConnectedToInternet) as Error
client.results = [.httpFailure(urlError), .httpSuccess(testData: .startRegistrationSuccess)]
await subject.perform(.startRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, Alert.networkResponseError(urlError) {
await self.subject.perform(.startRegistration)
})
try await alert.tapAction(title: Localizations.tryAgain)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(
client.requests[0].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertEqual(
client.requests[1].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertEqual(coordinator.routes.last, .completeRegistration(
emailVerificationToken: "0018A45C4D1DEF81644B54AB7F969B88D65\n",
userEmail: "example@email.com"
))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.startRegistration` presents an alert when the request times out.
/// When the user taps `Try again`, the create account request is made again.
func test_perform_startRegistration_timeout() async throws {
subject.state = .fixture()
let urlError = URLError(.timedOut) as Error
client.results = [.httpFailure(urlError), .httpSuccess(testData: .startRegistrationSuccess)]
await subject.perform(.startRegistration)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.message, urlError.localizedDescription)
try await alert.tapAction(title: Localizations.tryAgain)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(
client.requests[0].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertEqual(
client.requests[1].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertEqual(coordinator.routes.last, .completeRegistration(
emailVerificationToken: "0018A45C4D1DEF81644B54AB7F969B88D65\n",
userEmail: "example@email.com"
))
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.loadingOverlaysShown,
[
LoadingOverlayState(title: Localizations.creatingAccount),
LoadingOverlayState(title: Localizations.creatingAccount),
]
)
}
/// `perform(_:)` with `.startRegistration` and an invalid email navigates to an invalid email alert.
func test_perform_startRegistration_withInvalidEmail() async {
subject.state = .fixture(emailText: "exampleemail.com")
client.result = .httpFailure(StartRegistrationError.invalidEmail)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 0)
XCTAssertEqual(coordinator.alertShown.last, .invalidEmail)
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
}
/// `perform(_:)` with `.startRegistration` and a valid email creates the user's account.
func test_perform_startRegistration_withValidEmail() async {
subject.state = .fixture()
client.result = .httpSuccess(testData: .startRegistrationSuccess)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
client.requests[0].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.startRegistration` and a valid email surrounded by whitespace trims the whitespace and
/// creates the user's account
func test_perform_startRegistration_withValidEmailAndSpace() async {
subject.state = .fixture(emailText: " email@example.com ")
client.result = .httpSuccess(testData: .startRegistrationSuccess)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
client.requests[0].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `perform(_:)` with `.startRegistration` and a valid email with uppercase characters
/// converts the email to lowercase
/// and creates the user's account.
func test_perform_startRegistration_withValidEmailUppercased() async {
subject.state = .fixture(emailText: "EMAIL@EXAMPLE.COM")
client.result = .httpSuccess(testData: .startRegistrationSuccess)
await subject.perform(.startRegistration)
XCTAssertEqual(client.requests.count, 1)
XCTAssertEqual(
client.requests[0].url,
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
}
/// `receive(_:)` with `.dismiss` dismisses the view.
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `receive(_:)` with `.emailTextChanged(_:)` updates the state to reflect the change.
func test_receive_emailTextChanged() {
subject.state.emailText = ""
XCTAssertTrue(subject.state.emailText.isEmpty)
subject.receive(.emailTextChanged("updated email"))
XCTAssertTrue(subject.state.emailText == "updated email")
}
/// `receive(_:)` with `.toggleReceiveMarketing(_:)` updates the state to reflect the change.
func test_receive_toggleTermsAndPrivacy() {
subject.receive(.toggleReceiveMarketing(false))
XCTAssertFalse(subject.state.isReceiveMarketingToggleOn)
subject.receive(.toggleReceiveMarketing(true))
XCTAssertTrue(subject.state.isReceiveMarketingToggleOn)
subject.receive(.toggleReceiveMarketing(true))
XCTAssertTrue(subject.state.isReceiveMarketingToggleOn)
}
/// `didSaveEnvironment(urls:)` with URLs sets the region to self-hosted and sets the URLs in
/// the environment.
func test_didSaveEnvironment() async {
subject.state.region = .unitedStates
await subject.didSaveEnvironment(urls: EnvironmentUrlData(base: .example))
XCTAssertEqual(subject.state.region, .selfHosted)
XCTAssertEqual(subject.state.toast?.text, Localizations.environmentSaved)
XCTAssertEqual(
environmentService.setPreAuthEnvironmentUrlsData,
EnvironmentUrlData(base: .example)
)
}
/// `didSaveEnvironment(urls:)` with empty URLs doesn't change the region or the environment URLs.
func test_didSaveEnvironment_empty() async {
subject.state.region = .unitedStates
await subject.didSaveEnvironment(urls: EnvironmentUrlData())
XCTAssertEqual(subject.state.region, .unitedStates)
XCTAssertNil(environmentService.setPreAuthEnvironmentUrlsData)
}
/// `perform(.appeared)` with no pre-auth URLs defaults the region and URLs to the US environment.
func test_perform_appeared_loadsRegion_noPreAuthUrls() async {
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .unitedStates)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
}
/// `perform(.appeared)` with EU pre-auth URLs sets the state to the EU region and sets the
/// environment URLs.
func test_perform_appeared_loadsRegion_withPreAuthUrls_europe() async {
stateService.preAuthEnvironmentUrls = .defaultEU
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .europe)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultEU)
}
/// `perform(.appeared)` with self-hosted pre-auth URLs sets the state to the self-hosted region
/// and sets the URLs to the environment.
func test_perform_appeared_loadsRegion_withPreAuthUrls_selfHosted() async {
let urls = EnvironmentUrlData(base: .example)
stateService.preAuthEnvironmentUrls = urls
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .selfHosted)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, urls)
}
/// `perform(.appeared)` with US pre-auth URLs sets the state to the US region and sets the
/// environment URLs.
func test_perform_appeared_loadsRegion_withPreAuthUrls_unitedStates() async {
stateService.preAuthEnvironmentUrls = .defaultUS
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .unitedStates)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
}
/// `perform(.appeared)` with US pre-auth URLs sets the state to the US region and sets the
/// environment URLs.
/// Test if isReceiveMarketingToggle is On
func test_perform_appeared_loadsRegion_withPreAuthUrls_unitedStates_isReceiveMarketingToggle_on() async {
stateService.preAuthEnvironmentUrls = .defaultUS
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .unitedStates)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
XCTAssertTrue(subject.state.isReceiveMarketingToggleOn)
}
/// `perform(.appeared)` with EU pre-auth URLs sets the state to the EU region and sets the
/// environment URLs.
/// Test if isReceiveMarketingToggle is Off
func test_perform_appeared_loadsRegion_withPreAuthUrls_europe_isReceiveMarketingToggle_off() async {
stateService.preAuthEnvironmentUrls = .defaultEU
await subject.perform(.appeared)
XCTAssertEqual(subject.state.region, .europe)
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultEU)
XCTAssertFalse(subject.state.isReceiveMarketingToggleOn)
}
}
class MockStartRegistrationDelegate: StartRegistrationDelegate {
var didChangeRegionCalled: Bool = false
func didChangeRegion() async {
didChangeRegionCalled = true
}
} // swiftlint:disable:this file_length

View File

@ -0,0 +1,42 @@
import SwiftUI
// MARK: - StartRegistrationState
/// An object that defines the current state of a `StartRegistrationView`.
///
struct StartRegistrationState: Equatable {
// MARK: Properties
/// The text in the email text field.
var emailText: String = ""
/// Whether the terms and privacy toggle is on.
var isReceiveMarketingToggleOn: Bool = false
/// The text in the name text field.
var nameText: String = ""
/// The text in the receive marketing emails toggle
var receiveMarketingEmailsText: String {
Localizations.getAdviceAnnouncementsAndResearchOpportunitiesFromBitwardenInYourInboxUnsubscribeAtAnyTime(
ExternalLinksConstants.unsubscribeFromMarketingEmails
)
}
/// The region selected by the user.
var region: RegionType = .europe
/// The value which determines if the toggle is shown
var showReceiveMarketingToggle = true
/// Terms and privacy disclaimer text
var termsAndPrivacyDisclaimerText: String {
Localizations.byContinuingYouAgreeToTheTermsOfServiceAndPrivacyPolicy(
ExternalLinksConstants.termsOfService,
ExternalLinksConstants.privacyPolicy
)
}
/// A toast message to show in the view.
var toast: Toast?
}

View File

@ -0,0 +1,134 @@
import SwiftUI
// MARK: - StartRegistrationView
/// A view that allows the user to create an account.
///
struct StartRegistrationView: View {
// MARK: Properties
/// An action that opens URLs.
@Environment(\.openURL) private var openURL
/// The store used to render the view.
@ObservedObject var store: Store<StartRegistrationState, StartRegistrationAction, StartRegistrationEffect>
// MARK: View
var body: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 0) {
email
.padding(.bottom, 8)
RegionSelector(
selectorLabel: Localizations.creatingOn,
regionName: store.state.region.baseUrlDescription
) {
await store.perform(.regionTapped)
}
}
name
receiveMarketingToggle
continueButton
termsAndPrivacyText
.frame(maxWidth: .infinity)
}
.navigationBar(title: Localizations.createAccount, titleDisplayMode: .inline)
.scrollView()
.task {
await store.perform(.appeared)
}
.toolbar {
cancelToolbarItem {
store.send(.dismiss)
}
}
.toast(store.binding(
get: \.toast,
send: StartRegistrationAction.toastShown
))
}
// MARK: Private views
/// The text fields for the user's email and password.
private var email: some View {
BitwardenTextField(
title: Localizations.emailAddress,
text: store.binding(
get: \.emailText,
send: StartRegistrationAction.emailTextChanged
),
accessibilityIdentifier: "EmailAddressEntry"
)
.textFieldConfiguration(.email)
}
/// The text fields for the user's email and password.
private var name: some View {
BitwardenTextField(
title: Localizations.name,
text: store.binding(
get: \.nameText,
send: StartRegistrationAction.nameTextChanged
),
accessibilityIdentifier: "nameEntry"
)
.textFieldConfiguration(.username)
}
/// The button pressed when the user attempts to create the account.
private var continueButton: some View {
Button {
Task {
await store.perform(.startRegistration)
}
} label: {
Text(Localizations.continue)
}
.accessibilityIdentifier("ContinueButton")
.buttonStyle(.primary())
}
/// The button pressed when the user attempts to create the account.
private var termsAndPrivacyText: some View {
Text(LocalizedStringKey(store.state.termsAndPrivacyDisclaimerText))
.styleGuide(.footnote)
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.padding([.bottom], 32)
.multilineTextAlignment(.center)
}
/// A toggle for the terms and privacy agreement.
@ViewBuilder private var receiveMarketingToggle: some View {
if store.state.showReceiveMarketingToggle {
Toggle(isOn: store.binding(
get: \.isReceiveMarketingToggleOn,
send: StartRegistrationAction.toggleReceiveMarketing
)) {
Text(LocalizedStringKey(store.state.receiveMarketingEmailsText))
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.styleGuide(.subheadline)
}
.accessibilityIdentifier("ReceiveMarketingToggle")
.foregroundColor(Color(asset: Asset.Colors.textPrimary))
.toggleStyle(.bitwarden)
.id(ViewIdentifier.StartRegistration.receiveMarketing)
}
}
}
// MARK: Previews
#if DEBUG
#Preview {
StartRegistrationView(store: Store(processor: StateProcessor(state: StartRegistrationState())))
}
#endif

View File

@ -0,0 +1,120 @@
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest
@testable import BitwardenShared
// MARK: - StartRegistrationViewTests
class StartRegistrationViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<StartRegistrationState, StartRegistrationAction, StartRegistrationEffect>!
var subject: StartRegistrationView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: StartRegistrationState())
let store = Store(processor: processor)
subject = StartRegistrationView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// 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)
}
/// Updating the text field dispatches the `.emailTextChanged()` action.
func test_emailField_updateValue() throws {
let textfield = try subject.inspect().find(viewWithId: Localizations.emailAddress).textField()
try textfield.setInput("text")
XCTAssertEqual(processor.dispatchedActions.last, .emailTextChanged("text"))
}
/// Updating the text field dispatches the `.nameTextChanged()` action.
func test_nameField_updateValue() throws {
let textfield = try subject.inspect().find(viewWithId: Localizations.name).textField()
try textfield.setInput("user name")
XCTAssertEqual(processor.dispatchedActions.last, .nameTextChanged("user name"))
}
/// Tapping the continue button performs the `.StartRegistration` effect.
func test_continueButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.continue)
try button.tap()
waitFor(!processor.effects.isEmpty)
XCTAssertEqual(processor.effects.last, .startRegistration)
}
/// Tapping the region button dispatches the `.regionPressed` action.
func test_regionButton_tap() throws {
let button = try subject.inspect().find(
button: "\(Localizations.creatingOn): \(subject.store.state.region.baseUrlDescription)"
)
try button.tap()
waitFor(!processor.effects.isEmpty)
XCTAssertEqual(processor.effects.last, .regionTapped)
}
/// Tapping the receive marketing toggle dispatches the `.toggleReceiveMarketing()` action.
func test_receiveMarketingToggle_tap() 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(viewWithId: ViewIdentifier.StartRegistration.receiveMarketing).toggle()
try toggle.tap()
XCTAssertEqual(processor.dispatchedActions.last, .toggleReceiveMarketing(true))
}
// MARK: Snapshots
/// Tests the view renders correctly when the text fields are all empty.
func test_snapshot_empty() {
assertSnapshot(matching: subject, as: .defaultPortrait)
}
/// Tests the view renders correctly when the text fields are all populated.
func test_snapshot_textFields_populated() throws {
processor.state.emailText = "email@example.com"
processor.state.nameText = "user name"
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
/// Tests the view renders correctly when the text fields are all populated with long text.
func test_snapshot_textFields_populated_long() throws {
processor.state.emailText = "emailmmmmmmmmmmmmmmmmmmmmm@exammmmmmmmmmmmmmmmmmmmmmmmmmmmmmmple.com"
processor.state.nameText = "user name name name name name name name name name name name name name name"
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
/// Tests the view renders correctly when the toggles are on.
func test_snapshot_toggles_on() throws {
processor.state.isReceiveMarketingToggleOn = true
assertSnapshot(matching: subject, as: .defaultPortrait)
}
/// Tests the view renders correctly when the marketing toggle is hidden.
func test_snapshot_marketingToggle_hidden() throws {
processor.state.showReceiveMarketingToggle = false
assertSnapshot(matching: subject, as: .defaultPortrait)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@ -0,0 +1,89 @@
import Foundation
// MARK: - RegionHelper
/// Helper class with common functionality related to the region selector.
///
class RegionHelper {
/// Used to perform navigations and showing alert
let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// Service used to get environment information
let stateService: StateService
/// The delegate for the processor that is notified when the user saves their environment settings.
weak var delegate: RegionDelegate?
// MARK: Initialization
/// Creates a new `RegionHelper`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - delegate: The delegate for the processor.
/// - stateService: The services used by the helper .
///
init(coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
delegate: RegionDelegate,
stateService: StateService) {
self.coordinator = coordinator
self.delegate = delegate
self.stateService = stateService
}
/// Builds an alert for region selection and navigates to the alert.
///
func presentRegionSelectorAlert(title: String, currentRegion: RegionType?) async {
let actions = RegionType.allCases.map { region in
AlertAction(title: region.baseUrlDescription, style: .default) { _ in
if let urls = region.defaultURLs {
await self.delegate?.setRegion(region, urls)
} else {
await self.coordinator.navigate(
to: .selfHosted(currentRegion: currentRegion ?? .unitedStates),
context: self.delegate
)
}
}
}
let cancelAction = AlertAction(title: Localizations.cancel, style: .cancel)
let alert = Alert(
title: title,
message: nil,
preferredStyle: .actionSheet,
alertActions: actions + [cancelAction]
)
await coordinator.showAlert(alert)
}
/// Sets the region to the last used region.
///
func loadRegion() async {
guard let urls = await stateService.getPreAuthEnvironmentUrls() else {
await delegate?.setRegion(.unitedStates, .defaultUS)
return
}
if urls.base == EnvironmentUrlData.defaultUS.base {
await delegate?.setRegion(.unitedStates, urls)
} else if urls.base == EnvironmentUrlData.defaultEU.base {
await delegate?.setRegion(.europe, urls)
} else {
await delegate?.setRegion(.selfHosted, urls)
}
}
}
// MARK: - RegionDelegate
/// A delegate of `Region` that is notified when the user saves their environment settings.
///
protocol RegionDelegate: AnyObject {
/// Sets the region and the URLs to use.
///
/// - Parameters:
/// - region: The region to use.
/// - urls: The URLs that the app should use for the region.
///
func setRegion(_ region: RegionType, _ urls: EnvironmentUrlData) async
}

View File

@ -0,0 +1,146 @@
import BitwardenSdk
import XCTest
@testable import BitwardenShared
// MARK: - RegionHelperTests
class RegionHelperTests: BitwardenTestCase {
// MARK: Properties
var subject: RegionHelper!
var regionDelegate: MockRegionDelegate!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var stateService: MockStateService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
stateService = MockStateService()
regionDelegate = MockRegionDelegate()
subject = RegionHelper(
coordinator: coordinator.asAnyCoordinator(),
delegate: regionDelegate,
stateService: stateService
)
subject.delegate = regionDelegate
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap bitwarden.com.
func test_presentRegionSelectorAlert_us() async throws {
await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .unitedStates)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.creatingOn)
XCTAssertNil(alert.message)
XCTAssertEqual(alert.alertActions.count, 4)
XCTAssertEqual(alert.alertActions[0].title, "bitwarden.com")
try await alert.tapAction(title: "bitwarden.com")
XCTAssertTrue(regionDelegate.setRegionCalled)
XCTAssertEqual(regionDelegate.setRegionType, .unitedStates)
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.unitedStates.defaultURLs)
}
/// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap bitwarden.eu.
func test_presentRegionSelectorAlert_eu() async throws {
await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .unitedStates)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.creatingOn)
XCTAssertNil(alert.message)
XCTAssertEqual(alert.alertActions.count, 4)
XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu")
try await alert.tapAction(title: "bitwarden.eu")
XCTAssertTrue(regionDelegate.setRegionCalled)
XCTAssertEqual(regionDelegate.setRegionType, .europe)
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.europe.defaultURLs)
}
/// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap selfhosted.
func test_presentRegionSelectorAlert_selfHosted() async throws {
await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .europe)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.creatingOn)
XCTAssertNil(alert.message)
XCTAssertEqual(alert.alertActions.count, 4)
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
try await alert.tapAction(title: Localizations.selfHosted)
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe))
}
/// `presentRegionSelectorAlert(title:currentRegion)` with current region as nil default to us
func test_presentRegionSelectorAlert_nil() async throws {
await subject.presentRegionSelectorAlert(title: Localizations.loggingInOn, currentRegion: nil)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.loggingInOn)
XCTAssertNil(alert.message)
XCTAssertEqual(alert.alertActions.count, 4)
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
try await alert.tapAction(title: Localizations.selfHosted)
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .unitedStates))
}
/// `loadRegion()` with pre auth region as nil default to us
func test_loadRegion_nil() async throws {
stateService.preAuthEnvironmentUrls = nil
await subject.loadRegion()
XCTAssertTrue(regionDelegate.setRegionCalled)
XCTAssertEqual(regionDelegate.setRegionType, .unitedStates)
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.unitedStates.defaultURLs)
}
/// `loadRegion()` with pre auth region
func test_loadRegion_us() async throws {
stateService.preAuthEnvironmentUrls = .defaultUS
await subject.loadRegion()
XCTAssertTrue(regionDelegate.setRegionCalled)
XCTAssertEqual(regionDelegate.setRegionType, .unitedStates)
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.unitedStates.defaultURLs)
}
/// `loadRegion()` with pre auth region
func test_loadRegion_eu() async throws {
stateService.preAuthEnvironmentUrls = .defaultEU
await subject.loadRegion()
XCTAssertTrue(regionDelegate.setRegionCalled)
XCTAssertEqual(regionDelegate.setRegionType, .europe)
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.europe.defaultURLs)
}
/// `loadRegion()` with pre auth region
func test_loadRegion_selfHosted() async throws {
stateService.preAuthEnvironmentUrls = EnvironmentUrlData(base: URL(string: "https://selfhosted.com"))
await subject.loadRegion()
XCTAssertTrue(regionDelegate.setRegionCalled)
XCTAssertEqual(regionDelegate.setRegionType, .selfHosted)
XCTAssertEqual(regionDelegate.setRegionUrls, EnvironmentUrlData(base: URL(string: "https://selfhosted.com")))
}
}
class MockRegionDelegate: RegionDelegate {
var setRegionCalled = false
var setRegionType: RegionType?
var setRegionUrls: EnvironmentUrlData?
func setRegion(_ region: BitwardenShared.RegionType, _ urls: BitwardenShared.EnvironmentUrlData) async {
setRegionCalled = true
setRegionType = region
setRegionUrls = urls
}
}

View File

@ -4,6 +4,24 @@ import Combine
import Foundation
import UIKit
// MARK: - AppLinksError
/// The errors thrown from a `AppProcessor`.
///
enum AppProcessorError: Error {
/// The received URL from AppLinks is malformed.
case appLinksInvalidURL
/// The received URL from AppLinks does not have the correct parameters.
case appLinksInvalidParametersForPath
/// The received URL from AppLinks does not have a valid path.
case appLinksInvalidPath
/// The operation to execute is invalid.
case invalidOperation
}
/// The `AppProcessor` processes actions received at the application level and contains the logic
/// to control the top-level flow through the app.
///
@ -137,6 +155,44 @@ public class AppProcessor {
}
}
/// Handle incoming URL from iOS AppLinks and redirect it to the correct navigation within the App
///
/// - Parameter incomingURL: The URL handled from AppLinks.
///
public func handleAppLinks(incomingURL: URL) {
guard let sanatizedUrl = URL(string: incomingURL.absoluteString.replacingOccurrences(of: "/#/", with: "/")),
let components = URLComponents(url: sanatizedUrl, resolvingAgainstBaseURL: true) else {
return
}
// Check for specific URL components that you need.
guard let params = components.queryItems,
let host = components.host else {
services.errorReporter.log(error: AppProcessorError.appLinksInvalidURL)
return
}
guard components.path == "/finish-signup" else {
services.errorReporter.log(error: AppProcessorError.appLinksInvalidPath)
return
}
guard let email = params.first(where: { $0.name == "email" })?.value,
let verificationToken = params.first(where: { $0.name == "token" })?.value,
let fromEmail = params.first(where: { $0.name == "fromEmail" })?.value
else {
services.errorReporter.log(error: AppProcessorError.appLinksInvalidParametersForPath)
return
}
coordinator?.navigate(to: AppRoute.auth(
AuthRoute.completeRegistrationFromAppLink(
emailVerificationToken: verificationToken,
userEmail: email,
fromEmail: Bool(fromEmail) ?? true,
region: host.contains(RegionType.europe.baseUrlDescription) ? .europe : .unitedStates
)))
}
// MARK: Autofill Methods
/// Returns a `ASPasswordCredential` that matches the user-requested credential which can be
@ -451,10 +507,4 @@ extension AppProcessor: Fido2UserInterfaceHelperDelegate {
}
}
// MARK: - AppProcessorError
/// Errors that can happen inside the `AppProcessor`.
enum AppProcessorError: Error {
/// The operation to execute is invalid.
case invalidOperation
} // swiftlint:disable:this file_length
// swiftlint:disable:this file_length

View File

@ -209,6 +209,107 @@ class AppProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertIdentical(syncService.delegate, subject)
}
/// `handleAppLinks(URL)` navigates the user based on the input URL.
func test_init_handleAppLinks() {
let url = URL(string:
"https://bitwarden.com/#/finish-signup?email=example@email.com&token=verificationtoken&fromEmail=true"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
emailVerificationToken: "verificationtoken",
userEmail: "example@email.com",
fromEmail: true,
region: .unitedStates
)))
}
/// `handleAppLinks(URL)` navigates the user based on the input URL with EU region.
func test_init_handleAppLinks_regionEU() {
let url = URL(string:
"https://bitwarden.eu/#/finish-signup?email=example@email.com&token=verificationtoken&fromEmail=true"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
emailVerificationToken: "verificationtoken",
userEmail: "example@email.com",
fromEmail: true,
region: .europe
)))
}
/// `handleAppLinks(URL)` navigates the user based on the input URL with wrong fromEmail value.
func test_init_handleAppLinks_fromEmail_notBool() {
let url = URL(string:
"https://bitwarden.eu/#/finish-signup?email=example@email.com&token=verificationtoken&fromEmail=potato"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
emailVerificationToken: "verificationtoken",
userEmail: "example@email.com",
fromEmail: true,
region: .europe
)))
}
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidURL`.
func test_init_handleAppLinks_invalidURL() {
let noPathUrl = URL(string: "https://bitwarden.com/#/email=example@email.com&token=verificationtoken")
subject.handleAppLinks(incomingURL: noPathUrl!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
let noParamsUrl = URL(string: "https://bitwarden.com/#/finish-signup/")
subject.handleAppLinks(incomingURL: noParamsUrl!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
let invalidHostUrl = URL(string: "/finish-signup?email=example@email.com")
subject.handleAppLinks(incomingURL: invalidHostUrl!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
XCTAssertEqual(errorReporter.errors.count, 1)
}
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidPath`.
func test_init_handleAppLinks_invalidPath() {
let url = URL(
string: "https://bitwarden.com/#/not-valid?email=example@email.com&token=verificationtoken&fromEmail=true"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidPath)
}
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidParametersForPath`.
func test_init_handleAppLinks_invalidParametersForPath() {
var url = URL(
string: "https://bitwarden.com/#/finish-signup?token=verificationtoken&fromEmail=true"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
url = URL(
string: "https://bitwarden.com/#/finish-signup?email=example@email.com&fromEmail=true"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
url = URL(
string: "https://bitwarden.com/#/finish-signup?email=example@email.com&token=verificationtoken"
)
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
}
/// `init()` starts the upload-event timer and attempts to upload events.
func test_init_uploadEvents() {
XCTAssertNotNil(subject.sendEventTimer)

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "email_check.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,21 @@
<svg width="413" height="114" viewBox="0 0 413 114" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2313_25967)">
<path d="M260.144 44.6249V59.0358M192.864 14.7411L201.036 8.64331C204.02 6.41676 208.121 6.4503 211.068 8.72534L212.838 10.0913M164.614 35.8208L156.151 42.1362C154.052 43.7024 152.816 46.1675 152.816 48.7862V100.482C152.816 105.065 156.531 108.78 161.113 108.78H251.847C256.429 108.78 260.144 105.065 260.144 100.482V71.0536" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M165.375 54.5231V17.8322C165.375 16.3047 166.614 15.0664 168.141 15.0664H206.805" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M175.322 27.207H194.107" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M175.322 38.3867H193.56" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M175.322 49.5664H196.569" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M175.322 60.7461H203.134" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M257.859 106.495L223.49 74.7673C220.935 72.4091 217.586 71.0996 214.109 71.0996H197.24C193.649 71.0996 190.199 72.4967 187.619 74.9953L155.098 106.495" stroke="#175DDC" stroke-width="2.76577"/>
<path d="M220.364 71.5786L231.035 65.459" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<line x1="1.38289" y1="-1.38289" x2="46.4939" y2="-1.38289" transform="matrix(0.858543 0.512741 0.512741 -0.858543 153.386 46.5527)" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
<path d="M260.348 35.0897C260.348 51.7757 246.821 65.3024 230.135 65.3024C213.449 65.3024 199.922 51.7757 199.922 35.0897C199.922 18.4036 213.449 4.87695 230.135 4.87695C246.821 4.87695 260.348 18.4036 260.348 35.0897Z" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M256.301 35.0893C256.301 49.4393 244.533 61.0723 230.016 61.0723M230.016 9.10639C215.499 9.10639 203.731 20.7393 203.731 35.0893" stroke="#175DDC" stroke-width="1.38289" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M254.253 53.5511L258.87 58.1684L276.214 75.5124C277.564 76.8625 277.564 79.0515 276.214 80.4017L275.492 81.1232C274.142 82.4733 271.953 82.4733 270.603 81.1232L253.259 63.7792L248.642 59.1619" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_2313_25967">
<rect width="143.82" height="112.705" fill="white" transform="translate(134.838 0.570312)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -914,6 +914,19 @@
"FilePassword" = "File password";
"ConfirmFilePassword" = "Confirm file password";
"FilePasswordDescription" = "This password will be used to export and import this file";
"CreatingOn" = "Creating on";
"CheckYourEmail" = "Check your email";
"FollowTheInstructionsInTheEmailSentToXToContinueCreatingYourAccount" = "Follow the instructions in the email sent to %1$@ to continue creating your account.";
"NoEmailGoBackToEditYourEmailAddress" = "No email? **[Go back](https://)** to edit your email address.";
"OrLogInYouMayAlreadyHaveAnAccount." = "Or **[log in](https://)**, you may already have an account.";
"OpenEmailApp" = "Open email app";
"SetPassword" = "Set password";
"FinishCreatingYourAccountForXBySettingAPassword." = "Finish creating your account for %1$@ by setting a password.";
"VerifyingEmail" = "Verifying email";
"EmailVerified" = "Email verified";
"AccountSuccessfullyCreated" = "Account successfully created!";
"ByContinuingYouAgreeToTheTermsOfServiceAndPrivacyPolicy" = "By continuing, you agree to the **[Terms of Service](%1$@)** and **[Privacy Policy](%2$@)**";
"GetAdviceAnnouncementsAndResearchOpportunitiesFromBitwardenInYourInboxUnsubscribeAtAnyTime." = "Get advice, announcements, and research opportunities from Bitwarden in your inbox. **[Unsubscribe](%1$@)** at any time.";
"OrganizationUnassignedItemsMessageUSEUDescriptionLong" = "Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible.";
"UnknownAccount" = "Unknown account";
"UserVerificationForPasskey" = "User verification for passkey";

View File

@ -14,4 +14,18 @@ enum ViewIdentifier {
/// An identifier for the terms and privacy toggle.
case termsAndPrivacy
}
/// Identifiers used on the `StartRegistrationView`.
///
enum StartRegistration: String, Equatable, Hashable {
/// An identifier for the terms and privacy toggle.
case receiveMarketing
}
/// Identifiers used on the `CompleteRegistrationView`.
///
enum CompleteRegistration: String, Equatable, Hashable {
/// An identifier for the check for breaches toggle.
case checkBreaches
}
}

View File

@ -0,0 +1,58 @@
// MARK: - AsyncButton
import SwiftUI
/// A wrapper around SwiftUI's `Button` that used to trigger the region selector Alert
///
struct RegionSelector: View {
// MARK: Properties
/// The async action to perform when the user interacts with the button.
let action: () async -> Void
/// A binding to the toast to show.
var regionName: String
/// The text that is shown before the action text
var selectorLabel: String
var body: some View {
Button {
Task {
await action()
}
} label: {
HStack(spacing: 4) {
Group {
Text("\(selectorLabel): ")
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
+ Text(regionName).bold()
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
}
.styleGuide(.footnote)
Image(decorative: Asset.Images.downAngle)
.scaledFrame(width: 12, height: 12)
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
}
}
.accessibilityIdentifier("RegionSelectorDropdown")
}
// MARK: Initialization
/// Creates a new `RegionSelector`.
///
/// - Parameters:
/// - action: The async action to perform when the user interacts with this selector.
///
init(
selectorLabel: String,
regionName: String,
action: @escaping () async -> Void
) {
self.action = action
self.regionName = regionName
self.selectorLabel = selectorLabel
}
}