Skip to content

Instantly share code, notes, and snippets.

@JimCampagno
Created April 26, 2018 19:24
Show Gist options
  • Save JimCampagno/5d75b9e3426e5960fcdfa2b0b89860cf to your computer and use it in GitHub Desktop.
Save JimCampagno/5d75b9e3426e5960fcdfa2b0b89860cf to your computer and use it in GitHub Desktop.

Registration Flow - iOS

A crucial type in the app is the CognitoStore. There's only ever one instance of this type that lives throughout the life-cycle of the app. Everywhere in our app, a dev has access to this one instance through the sharedInstance static stored property on the CognitoStore.

Important to note is the init function on this type. It sets everything up, most imporantly the userPool stored property of type AWSCognitoIdentityUserPool. This in turn will provide access to the AWSCognitoIdentityUser that exists on the AWSCognitoIdentityUserPool. This is done by calling currentUser() on the AWSCognitoIdentityUserPool instance.

We expose the AWSCognitoIdentityUser in the form of a computed property named currentUser.

Here's how accessing this currentUser looks like throughout the app:

CognitoStore.sharedInstance.currentUser

NOTE: There might be some redundant calls going on in the init function here. As well, I'm sure parts of it can be refactored, but as it stands.. it's getting the job done with no known issues.

CognitoStore

import Foundation
import AWSCore
import AWSCognitoIdentityProvider

/// 🕵️ CognitoStore
final class CognitoStore: NSObject {

    // MARK: - Singleton

   static let sharedInstance = CognitoStore()

    // MARK: - Properties

    var credentialsProvider: AWSCognitoCredentialsProvider
    var userPool: AWSCognitoIdentityUserPool
    var configuration: AWSServiceConfiguration

    var awsCredentials: AWSCredentialsType
    var currentUser: AWSCognitoIdentityUser? {
        return self.userPool.currentUser()
    }

    // The VC that starts the login/signup process should be set as the delegate.
    var delegate: AWSCognitoIdentityInteractiveAuthenticationDelegate {
        get {
            return userPool.delegate
        }
        set {
            userPool.delegate = newValue
        }
    }

    // MARK: - Initializer(s)

    private override init() {
        // Placed this in a private init to make the CognitoStore a true singleton. Several of these calls
        // can only be made once, as they configure settings for the entire AWS Cognito sdk, e.g.
        // http://amzn.to/2riUP44.
        configuration = AWSServiceConfiguration(region: .USEast1, credentialsProvider: nil)

        #if DEBUG
            awsCredentials = AWSCredentialsFactory.getCredentials(for: .staging)
        #else
           awsCredentials = AWSCredentialsFactory.getCredentials(for: .production)
        #endif

        let userPoolConfiguration = AWSCognitoIdentityUserPoolConfiguration(
            clientId: awsCredentials.clientId,
            clientSecret: awsCredentials.clientSecret,
            poolId: awsCredentials.poolId)

        AWSCognitoIdentityUserPool.register(with: configuration,
                                            userPoolConfiguration: userPoolConfiguration,
                                            forKey: awsCredentials.userPoolKey)

        self.userPool = AWSCognitoIdentityUserPool(forKey: awsCredentials.userPoolKey)

        self.credentialsProvider = AWSCognitoCredentialsProvider(
            regionType: .USEast1,
            identityPoolId: awsCredentials.identityPoolId,
            identityProviderManager: self.userPool)

        configuration = AWSServiceConfiguration(region: .USEast1,
                                                credentialsProvider: self.credentialsProvider)
        AWSServiceManager.default().defaultServiceConfiguration = configuration
        super.init()
    }
}

Does User Need To Finish Registering?

The first thing we do (currently happening in FinalLoggedOutHomeViewController (this name needs to be refactored) once the app initially launches) is to make the check to see if we have a current user, and if so.. check to see if they need to finish registration.

When the app first launches, we call through to a method doesCurrentUserNeedToFinishRegistration(), which is implemented by the HomeViewControllerViewModel:

NOTE: This method signature is better served to be a Single.

func doesCurrentUserNeedToFinishRegistration() -> Observable<Bool> {
    guard let currentUser = currentUser else {
        return Observable.just(false)
    }

    switch currentUser.confirmedStatus {
        /* A confirmed and unconfirmed status does not need to be handled. In the issue we're solving
         where a user might have confirmed upon registration but didn't fill out the profile information
         necessary to complete registration, confirmed and uncofirmed does not get called. */
    case .confirmed, .unconfirmed:
        return Observable.just(false)
        
        /* An unknown status comes down (in this scenario) when during the registration flow, a user
         had completed part 1 and part 2 of registration (entering in the confirmation code) but then
         didn't complete filling out their profile information which is required to finalize registration.
         But this isn't the ONLY scenario where we could find ourselves in this unknown enum. */
    case .unknown:
        return Observable.create { [unowned self] observer in
            self.serviceProvider.patientService.fetchCurrentPatient()
                .observeOn(MainScheduler.instance)
                .subscribe(onNext: nil, onError: { error in
                    /* If the error is of type ObjectMapper.MapError, it means that through our call
                     to fetchCurrentPatient() , we were unable to map the response in creating an instance
                     of Patient. To get this far in the process and being unable to map to a Patient would mean that
                     the user DIDN'T complete registration because there are missing parameters. */
                    observer.onNext(error is ObjectMapper.MapError)
                    observer.onCompleted()
                }, onCompleted: {
                    observer.onNext(false)
                    observer.onCompleted()
                })
                .disposed(by: self.bag)
            return Disposables.create()
        }
    }
}

Registration - Step One

Instead of providing a written step-by-step of how we handle this process, I've included the most important code snippets from the two types that power this UIViewController. Those types are the RegistrationViewControllerStepOne and RegistrationVCStepOneViewModel.

RegistrationViewControllerStepOne

If a user enters in a valid E-mail, Phone number and password and then hits register, the following will happen:

BetterProgressHUD.show()
viewModel.signUpUser(email: email, password: password)
    .observeOn(MainScheduler.instance)
    .subscribe { [weak self] event in
        switch event {
        case .next(let signUpResponse):
            self?.handle(signUpResponse: signUpResponse)
        case .error(let error):
            self?.viewModel.retrieveUser(with: email)
            self?.handle(signUpUserError: error as NSError)
        case .completed:
            break
        }
    }
    .disposed(by: viewModel.bag)

The following methods exist as extensions on the RegistrationViewControllerStepOne:

// MARK: - ⬆️ Sign Up
fileprivate extension RegistrationViewControllerStepOne {

    /// Handles the instance of CognitoSignUpResponse we get back from the
    /// request that is attempting to sign in the user after they inputted in
    /// an e-mail, phone-number and password
    ///
    /// - Parameter signUpResponse: CognitoSignUpResponse instance from request
    func handle(signUpResponse: CognitoSignupResponse) {
        viewModel.user = signUpResponse.user
        switch signUpResponse.user.confirmedStatus {
        case .confirmed, .unknown:
            signInUser()
        case .unconfirmed:
            continueToStepTwoInRegistration()
        }
    }

    func handle(signUpUserError error: NSError) {
        guard let awsError = AWSCognitoIdentityProviderErrorType(rawValue: error.code) else {
            // TODO: Handle the fact that an error was thrown but we don't know the type of error.
            // TODO: Before displaying error, Dismiss Alert
            return
        }

        switch awsError {
        case .expiredCode:
            handleUserWithExpiredCode()
        case .usernameExists:
            signInUser()
        case .userNotConfirmed:
            continueToStepTwoInRegistration()
        default:
            // TODO: display general error here.
            // TODO: Before displaying error, Dismiss Alert
            break
        }
    }

    func handleUserWithExpiredCode() {
        viewModel.resendConfirmation()
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] _ in
                BetterProgressHUD.dismiss()
                self?.continueToStepTwoInRegistration()
                }, onError: { error in
                    // TODO: Display Error
                    // TODO: Before displaying error, Dismiss Alert
                    log.debug(error)
            })
            .disposed(by: viewModel.bag)
    }

    func signInUser() {
        guard let email = emailUserInputView.text?.lowercased(),
            let password = passwordUserInputView.text else {
                BetterProgressHUD.dismiss()
                BetterAlert.display(config: .registrationNotComplete)
                return
        }

        viewModel.signInUser(email: email, password: password)
            .observeOn(MainScheduler.instance)
            .subscribe { [weak self] event in
                switch event {
                case .next:
                    self?.handleUserSuccessfullySigningIn() // ✅
                case .error(let error):
                    BetterProgressHUD.dismiss()
                    self?.handle(signInError: error as NSError)
                case .completed:
                    break
                }
            }
            .disposed(by: viewModel.bag)
    }

    func handle(signInError error: NSError) {
        guard let awsError = AWSCognitoIdentityProviderErrorType(rawValue: error.code) else {
            // TODO: Handle the fact that an error was thrown but we don't know the type of error.
            return
        }

        switch awsError {
        case .userNotConfirmed:
            continueToStepTwoInRegistration()
        case .invalidPassword, .notAuthorized:
            BetterAlert.display(config: .incorrectPasswordOnRegistration)
        case .userLambdaValidation:
            BetterAlert.display(config: .emailExistsOnClinic)
        default:
            // TODO: display general error here.
            break

        }
    }

}

// MARK: - ➡️ Signing In
fileprivate extension RegistrationViewControllerStepOne {

    // ✅
    func handleUserSuccessfullySigningIn() {
        viewModel.fetchCurrentPatient()
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] _ in
                BetterProgressHUD.dismiss()
                LoggedInState.shared.userHasLoggedIn()
                NewAppStorage().didCompleteCognitoSignup = true
                self?.handleSuccessfullyFetchingCurrentPatient() // ✅
                }, onError: { [weak self] error in
                    self?.handleFetchCurrentPatientError(error) // ⚠️
            })
            .disposed(by: viewModel.bag)
    }

    // ✅
    func handleSuccessfullyFetchingCurrentPatient() {
        let config = BetterAlertConfig.successfullSignInAfterSignUpRequst(email: emailUserInputView.text)
        BetterAlert.display(config: config, leftButtonAction: nil) { [weak self] in
            self?.dismiss(animated: true, completion: nil)
        }
    }

    // ⚠️
    func handleFetchCurrentPatientError(_ error: Error) {
        switch error {
        case is ResponseError:
            if let responseError = error as? ResponseError, let success = responseError.success,
                !success, let message = responseError.message, message == "Patient Not Found" {
                createEmptyPatientAndMoveToFinalRegistration()
            } else {
                BetterProgressHUD.dismiss()
                let additionalText = (error as! ResponseError).message ?? ""
                let bodyText = "We're unable to sign you in with the provided e-mail, please try again later. " + additionalText
                let config = BetterAlertConfig(titleText: "Sign-in Error", bodyText: bodyText)
                BetterAlert.display(config: config, rightButtonAction: {
                    MainTabBarStore.shared.dispatch(action: .logout(displayAlert: false))
                })
            }
        case is ObjectMapper.MapError:
            BetterProgressHUD.dismiss()
            moveToFinalRegistration()
        default:
            BetterProgressHUD.dismiss()
            let config = BetterAlertConfig.unableToFetchCurrentPatient(email: emailUserInputView.text,
                                                                       message: error.localizedDescription)

            BetterAlert.display(config: config, rightButtonAction: {
                MainTabBarStore.shared.dispatch(action: .logout(displayAlert: false))
            })
        }
    }

    func createEmptyPatientAndMoveToFinalRegistration() {
        viewModel.createEmptyPatient()
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] _ in
                BetterProgressHUD.dismiss()
                self?.moveToFinalRegistration()
                }, onError: { error in
                    BetterProgressHUD.dismiss()
                    BetterAlert.display(config: .unableToCreateEmptyUser(error))
            })
            .disposed(by: viewModel.bag)
    }

    func moveToFinalRegistration() {
        let finalRegisterViewModel = FinalRegistrationViewModel(phone: phoneNumberUserInputView.text ?? "")
        let vc = FinalRegistrationViewController.makeViewController(viewModel: finalRegisterViewModel)
        navigationController?.pushViewController(vc, animated: true)
    }

}

The RegistrationVCStepOneViewModel is used by the RegistrationViewControllerStepOne:

final class RegistrationVCStepOneViewModel: ViewModel {

    var user: CognitoUser?
    fileprivate let cognitoStore = CognitoStore.sharedInstance

}

// MARK: - Registration
extension RegistrationVCStepOneViewModel {

    func signUpUser(email: String, password: String) -> Observable<CognitoSignupResponse> {
        return serviceProvider.cognitoService.createUser(in: cognitoStore.userPool,
                                                         with: email,
                                                         password: password)
    }

    func signInUser(email: String, password: String) -> Observable<CognitoUserSession> {
        guard let user = user else {
            let error: AWSCognitoIdentityProviderErrorType = .unknown
            let nsError = NSError(domain: "AWS", code: error.rawValue, userInfo: nil)
            return Observable.error(nsError)
        }

        return serviceProvider.cognitoService.signInNow(user: user, username: email, password: password)
    }

    func resendConfirmation() -> Observable<CognitoConfirmResendResponse> {
        guard let user = user else {
            let error: AWSCognitoIdentityProviderErrorType = .unknown
            let nsError = NSError(domain: "AWS", code: error.rawValue, userInfo: nil)
            return Observable.error(nsError)
        }
        return serviceProvider.cognitoService.resendConfirmation(user)
    }

    func createEmptyPatient() -> Observable<Int> {
        return serviceProvider.patientService.createEmptyPatient()
    }

    func fetchCurrentPatient() -> Observable<Patient> {
        return serviceProvider.patientService.fetchCurrentPatient()
    }

    func retrieveUser(with email: String) {
        user = cognitoStore.userPool.getUser(email)
    }

}
// MARK: - Navigation
fileprivate extension RegistrationViewControllerStepOne {

    /// This method is only called if a user has successfuly registered. If they did,
    /// this method will fire off where we create an instance of the RegistrationViewControllerStepTwos
    /// viewModel and pass it along to our instance of RegistrationViewControllerStepTwo to then
    /// push onto the navigation stack
    func continueToStepTwoInRegistration() {
        guard let email = emailUserInputView.text,
            let password = passwordUserInputView.text,
            let phone = phoneNumberUserInputView.text,
            let user = viewModel.user else {
                // TODO: I don't think we can ever be in this scenario, but display alert anyway
                // TODO: Dismiss Progress HUD before displaying alert
                return
        }
        BetterProgressHUD.dismiss()
        let stepTwoViewModel = RegistrationVCStepTwoViewModel(email: email,
                                                              password: password,
                                                              phone: phone,
                                                              user: user)
        let vc = RegistrationViewControllerStepTwo.makeViewController(viewModel: stepTwoViewModel)
        navigationController?.pushViewController(vc, animated: true)
    }

}

Registration - Step Two

Instead of providing a written step-by-step of how we handle this process, I've included the most important code snippets from the two types that power this UIViewController. Those types are the RegistrationViewControllerStepTwo and RegistrationVCStepTwoViewModel.

Run through the code to see the path taken after a user enters in a validation code and then taps "CONFIRM":

// MARK: - Confirming The User
fileprivate extension RegistrationViewControllerStepTwo {

    func confirmUser() {
        guard let confirmationCode = confirmationCodeUserInputView.text else {
            BetterAlert.display(config: .inputConfirmationCode)
            return
        }
        BetterProgressHUD.show()
        NewAppStorage().didCompleteCognitoSignup = true
        viewModel.confirmUser(with: confirmationCode)
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] patientID in
                BetterProgressHUD.dismiss()
                self?.viewModel.patientID = patientID
                NewAppStorage().didCompleteCognitoSignup = false
                self?.proceedToFinalStageOfRegistration()
                }, onError: { [weak self] error in
                    BetterProgressHUD.dismiss()
                    self?.displayConfirmError(error)
                    NewAppStorage().didCompleteCognitoSignup = false
            })
            .disposed(by: viewModel.disposeBag)
    }

    func resendConfirmationCode() {
        BetterProgressHUD.show()
        NewAppStorage().didCompleteCognitoSignup = true
        viewModel.resendConfirmation()
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] _ in
                BetterProgressHUD.dismiss()
                NewAppStorage().didCompleteCognitoSignup = false
                self?.displayResentConfirmationAlert()
                }, onError: { [weak self] error in
                    BetterProgressHUD.dismiss()
                    self?.displayResentError(error)
                    NewAppStorage().didCompleteCognitoSignup = false
            })
            .disposed(by: viewModel.disposeBag)
    }

    func proceedToFinalStageOfRegistration() {
        removeSelfAsObserver()
        let finalRegistrationViewModel = FinalRegistrationViewModel(phone: viewModel.phone)
        let finalRegistrationVC = FinalRegistrationViewController.makeViewController(viewModel: finalRegistrationViewModel)
        navigationController?.pushViewController(finalRegistrationVC, animated: true)
    }

    func displayResentConfirmationAlert() {
        BetterAlert.display(config: .resentConfirmationCode(email: viewModel.email))
    }

    func displayConfirmError(_ error: Error) {
        let nsError = error as NSError
        guard nsError.domain.contains("AWS"),
           let awsError = AWSCognitoIdentityProviderErrorType(rawValue: nsError.code) else {
            BetterAlert.display(config: .confirmError(error))
            return
        }

        switch awsError {
        case .notAuthorized:
            BetterAlert.display(config: .forgotOnPasswordAfterConfirmingAccount, leftButtonAction: nil, rightButtonAction: { [weak self] in
                self?.navigationController?.popViewController(animated: true)
            })
        default:
            BetterAlert.display(config: .confirmError(error))
        }

    }

    func displayResentError(_ error: Error) {
        BetterAlert.display(config: .resentError(error))
    }

}

The RegistrationVCStepTwoViewModel it's making use of:

final class RegistrationVCStepTwoViewModel {

    let email: String
    let password: String
    let phone: String
    let user: CognitoUser
    let disposeBag = DisposeBag()
    var patientID: Int?

    fileprivate let cognitoStore = CognitoStore.sharedInstance
    fileprivate let serviceProvider = ServiceProvider()

    init(email: String, password: String, phone: String, user: CognitoUser) {
        self.email = email
        self.password = password
        self.phone = phone
        self.user = user
    }

    func confirmUser(with confirmationCode: String) -> Observable<Int> {
        return serviceProvider.cognitoService.confirm(user: user, with: confirmationCode)
            .flatMap { (_) -> Observable<CognitoUserSession> in
                return self.serviceProvider.cognitoService.signInNow(user: self.user,
                                                                     username: self.email,
                                                                     password: self.password)
            }
            .flatMap { (_) -> Observable<Int> in
                return self.serviceProvider.patientService.createEmptyPatient()
            }
    }

    func resendConfirmation() -> Observable<CognitoConfirmResendResponse> {
        return serviceProvider.cognitoService.resendConfirmation(user)
    }

}

Registration - Step Three (last step)

Instead of providing a written step-by-step of how we handle this process, I've included the most important code snippets from the two types that power this UIViewController. Those types are the FinalRegistrationViewController and FinalRegistrationViewModel.

After a user inputs the necessary info and then taps REGISTER:

// MARK: - Updating Patient
fileprivate extension FinalRegistrationViewController {

    func createPatient() {
        guard let date = dateOfBirthInputView.date,
            let firstName = firstNameInputView.text,
            let lastName = lastNameInputView.text,
            let genderCharacter = genderInputView.text?.first else {
                BetterAlert.display(config: .registrationNotComplete)
                return
        }

        let dictionary: [String: Any] = [
            "firstName": firstName,
            "lastName": lastName,
            "phone": viewModel.phone,
            "gender": String(genderCharacter),
            "birthday": createBirthday(from: date),
            "acceptPatientTOS": termsOfUseAgreementView.doesAgree,
            "acceptPatientPrivacy": privacyPolicyAgreementView.doesAgree,
            "acceptHIPAA": hippaAuthorizationAgreementView.doesAgree
        ]

        updatePatient(with: dictionary)
    }

    func createBirthday(from date: Date) -> String {
        return DateFormatter.bptBirthdayDateFormatter.string(from: date)
    }

    func updatePatient(with dictionary: [String: Any]) {
        BetterProgressHUD.show()
        viewModel.updatePatient(with: dictionary)
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] patient in
                BetterProgressHUD.dismiss()
                self?.handleOnNext(patient: patient)
                }, onError: { [weak self] error in
                    BetterProgressHUD.dismiss()
                    self?.displayErrorAlert(error: error)
            })
            .disposed(by: viewModel.disposeBag)
    }

    func handleOnNext(patient: Patient) {
        // TODO: Make these both static or both not
        NewAppStorage.persistPatient(patient)
        NewAppStorage().didCompleteCognitoSignup = true
        viewModel.serviceProvider.analyticsService.logUserId(from: patient)
        LoggedInState.shared.userHasLoggedIn()

        BetterAlert.display(config: .completedRegistration, leftButtonAction: nil) { [weak self] in
            self?.dismiss(animated: true, completion: {
                NotificationCenter.default.post(name: .loggedInWithUI, object: nil)
            })
        }
    }

}

The FinalRegistrationViewModel:

// MARK: - FinalRegistrationViewModelType
protocol FinalRegistrationViewModelType {

    // MARK: - ViewModel Inputs

    var patientDictionary: JSONDictionary { get }
    var locationDictionary: JSONDictionary { get }
    var imageDataString: Base64String { get set }
    var phone: String { get }
    var disposeBag: DisposeBag { get }
    var navigationTitle: String { get }
    var serviceProvider: ServiceProvider { get }

    // MARK: - ViewModel Outputs
    func updatePatient(with patientDictionary: JSONDictionary) -> Observable<Patient>
}

class FinalRegistrationViewModel: FinalRegistrationViewModelType {

    // MARK: - Properties

    let phone: String
    let disposeBag = DisposeBag()
    let serviceProvider = ServiceProvider()

    var patientDictionary: JSONDictionary = [:]
    var locationDictionary: JSONDictionary = [:]
    var imageDataString: Base64String = ""
    let navigationTitle = "Register (Step 3 of 3)"

    init(phone: String) {
        self.phone = phone
    }

    func updatePatient(with patientDictionary: JSONDictionary) -> Observable<Patient> {
        return serviceProvider.patientService.updatePatient(with: patientDictionary)
            .flatMap { patient in
                return self.serviceProvider.imageService.uploadBase64(resourceId: patient.patientId,
                                                                      base64String: self.imageDataString,
                                                                      imageUploadType: .userAvatar)
        }
            .flatMap { _ in
                return self.serviceProvider.patientService.fetchCurrentPatient()
        }
    }

}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment