controllers/registrationaid.js

/**
 * @module controllers/registrationaid
 * @author jesse reichler <mouser@donationcoder.com>
 * @copyright 5/1/19
 * @description
 * This module defines the RegistrationAid class, which provides support functions for user registration
 */

"use strict";


// requirement service locator
const jrequire = require("../helpers/jrequire");

// our helper modules
const JrResult = require("../helpers/jrresult");
const jrhValidate = require("../helpers/jrh_validate");

// require controllers
const arserver = jrequire("arserver");

// require models
const VerificationModel = jrequire("models/verification");
const UserModel = jrequire("models/user");
const LoginModel = jrequire("models/login");









/**
 * Provides support functions for user registration
 *
 * @class RegistrationAid
 */
class RegistrationAid {

	//---------------------------------------------------------------------------
	// constructor
	constructor() {
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	// this function is used to initialize form variables when presenting registration form
	// it gets values from session related if they have a bridged login or a session-remembered validation of their emai, etc.
	// which allows us to have multi-step registration
	async fillReqBodyWithSessionedFieldValues(jrContext) {
		// option fields
		let username, email, realName;

		// initial values for the form to present them with

		// ok now let's check if they are sessioned with a LoginId; if so we might get initial values from that
		const login = await arserver.getSessionedBridgedLogin(jrContext);
		if (login) {
			// bridged login, get their requested (or default) username
			email = login.getExtraDataField("email");
			realName = login.getExtraDataField("realName");
			username = login.getExtraDataField("username", realName);
			if (username) {
				username = await UserModel.fixImportedUsername(username);
			}
			// show them about their bridged login
			jrContext.pushSuccess("After your complete your registration, you will be able to login using " + login.getProviderLabel() + ".");
		}

		// previously sessioned with a verificationId? if so we could get initial values from that
		let verification = await arserver.getLastSessionedVerification(jrContext);
		if (verification && verification.isValidNewAccountEmailReady(jrContext)) {
			// ok we can get their initial registration data from their verification they already verified
			if (verification.key === "email") {
				email = verification.val;
				jrContext.pushSuccess("With your email address verified, you may now complete your registration.");
			}
			realName = verification.getExtraDataField("realName", realName);
			username = verification.getExtraDataField("username", username);
			email = verification.getExtraDataField("email", email);
		} else {
			// not relevant for us
			verification = null;
		}

		// store initial values for form in req.body, just as they would be if were were re-presending a failed form
		jrContext.req.body.username = username;
		jrContext.req.body.email = email;
		jrContext.req.body.realName = realName;
		if (verification) {
			// We have a verification that is still relevant for creating a new account, so we do we want to pass that along in the form? what if we don't?
			if (true) {
				// ATTN: now, as would be the case if we were re-presenting a form with errors, we might want to put the (plaintext) verification code back on the form to re-consume it on new submit
				// however, it's not clear the best way to do this.. do we want to embed in form being passed back to user or just keep it in session...
				// NOTE also that if they hit the registration page attempting to finish a registration, their passed verification code may ALSO be in req.body.code or req.params.code (see verify route)
				// there are alternatives.  We could not put the already-taken verification code back in the form, and just grab it from session when we go to process the registration form (feels kludgey)
				// Alternatively, we could not look for it in session at all, and just get keep passing it through as hidden let on form but taking it from the original very route (req.params.code)
				//  this has advantage of not needing session to save verification code; the only real problem with this is that i think we may depend on session saving verification info when we do a bridged login.. is that true?
				// The reason it's currently hard to pass it through as a hidden variable on the registration form after we receive it in a verify url link, is that we are REDIRECTING the user to the register page, rather than
				//  just rendering full registration form.. So it's not easy to pass along the verification code info other than via session.
				if (!jrContext.req.body.verificationCode) {
					jrContext.req.body.verificationCode = verification.getUniqueCode();
				}
			}
			if (false) {
				jrContext.req.body.verificationCodeHashed = verification.getUniqueCodeHashed();
			}
		}
	}
	//---------------------------------------------------------------------------

























	//---------------------------------------------------------------------------
	// New all in one registration/account form helper
	async processAccountAllInOneForm(jrContext) {
		let successRedirectTo;

		// get any verification code associated with this registration, to prove they own the email
		// verificationCode can come explicitly from the form (takes priority) OR the session if not in the form
		const verification = await this.getValidNewAccountVerificationFromRequestOrLastSession(jrContext);

		// depending on how we are invoked we may allow for missing fields
		// ATTN: note that it may be the case that a field is REQUIRED, but does not have to be present
		//  on the form if it is present in the verification record (e.g. they have verified their email)
		let requiredFields;
		if (verification) {
			// when we are following up on a verification, then this is a final registration
			requiredFields = this.calcRequiredRegistrationFieldsFinal();
		} else {
			// new registration, we may only need certain info, because we aren't creating user account yet, just pre-account verification without account to be created after they verify their email
			requiredFields = this.calcRequiredRegistrationFieldsInitial();
		}
		const flagEmailRequired = requiredFields.includes("email");
		const flagUsernameRequired = requiredFields.includes("username");
		const flagPasswordRequired = requiredFields.includes("password");
		const flagCheckDisallowedUsername = true;


		// get values from form submission, falling back on verification if there is one that is verified
		let email = jrContext.req.body.email;
		let username = jrContext.req.body.username;
		const password = jrContext.req.body.password;
		let realName = jrContext.req.body.realName;
		let passwordHashed;
		let flagVerifiedEmail = false;

		// blank values in form can assume verification values
		// this is so that when they signed up pre-account creation they could have supplies some of these as requested (but not guaranteed) values
		if (verification) {
			if (!email) {
				email = verification.getValEnsureKey("email");
				flagVerifiedEmail = true;
			} else {
				// they have provided an email -- if it doesn't match verificaiton email, then the verification is moot
				if (email !== verification.getValEnsureKey("email")) {
					flagVerifiedEmail = false;
				} else {
					flagVerifiedEmail = true;
				}
			}
			if (!username) {
				username = verification.getExtraDataField("username");
			}
			if (!password) {
				passwordHashed = verification.getExtraDataField("passwordHashed");
			}
			if (!realName) {
				realName = verification.getExtraDataField("realName");
			}
		}





		// ok now we want to validate fields
		// ATTN: there is some duplicated overlapping code here from user.js validateAndSaveNew() that we would ideally like to merge

		// valid email?
		email = await UserModel.validateEmail(jrContext.result, email, true, flagEmailRequired, null);

		// valid username?
		username = await UserModel.validateUsername(jrContext.result, username, true, flagUsernameRequired, flagCheckDisallowedUsername, null);

		// valid realName
		realName = await jrhValidate.validateRealName(jrContext.result, "realName", realName, false);

		// valid password?
		if (passwordHashed) {
			// we already have a valid hashed password for them, previously calculated and stored in verification object (and no new password specified), so we'll use that
		} else {
			passwordHashed = await UserModel.validatePlaintextPasswordConvertToHash(jrContext.result, password, flagPasswordRequired, false);
		}

		if (jrContext.isError()) {
			// error case, we can return now
			return successRedirectTo;
		}
		//---------------------------------------------------------------------------




		//---------------------------------------------------------------------------
		// user data object, used in both cases below
		const userObj = {
			email,
			username,
			realName,
			passwordHashed,
		};
		// ATTN: IMPORTANT NOTE
		// There are 2 cases we need to deal with here
		// Case 1: We already have verified proof they own this email, because they got here with a verificationCode that proves it (either provided in the form, or in their session)
		//  in which case we can create the account
		// Case 2: They somehow are on this page requesting a new account, without proof of they own the email (maybe they lost the verification coder, etc.)
		//  in this case, it's identical to asking for a registration
		// in case 1 we will complain if they try to use an email address in userObj that does not match the one in the verification;
		//  but alternately we could take a mismatch of email as a sign of case 2, and rather than complaining, just begin the email verification process again.
		//
		if (verification && flagVerifiedEmail) {
			// case 1, we can create the full account
			await this.createFullNewUserAccountForLoggedInUser(jrContext, verification, userObj);
			if (!jrContext.isError()) {
				if (arserver.isSessionLoggedIn(jrContext)) {
					// they have been logged in after verifying, so send them to their profile.
					successRedirectTo = "/profile";
				} else {
					// not logged in, so send them to login page to login
					successRedirectTo = "/login";
				}
				// drop down whether success or error
			}
		} else {
			// case 2, it's an initial registration attempt for which we need to send them a verification
			//
			// session user data (userId should be blank, but loginId might not be if they are doing this after a bridged login)
			const userId = arserver.getUntrustedLoggedInUserIdFromSession(jrContext);
			const loginId = arserver.getSessionedBridgedLoginId(jrContext);

			// create the email verification and mail it
			await VerificationModel.createVerificationNewAccountEmail(jrContext, email, userId, loginId, userObj);

			// add message on success
			if (!jrContext.isError()) {
				// success
				jrContext.pushSuccess("Please check for the verification email.  You will need to confirm that you have received it before your account can be created.");
				successRedirectTo = "/verify";
			}
		}
		//---------------------------------------------------------------------------

		// return tuple with result and suggested succes redirect
		return successRedirectTo;
	}
	//---------------------------------------------------------------------------




	//---------------------------------------------------------------------------
	calcRequiredRegistrationFieldsInitial() {
		// we can change this stuff if we want to force user to provide different info on initial registration
		// if we only require email, then we can't create account until they verify email, and thereafter fill in another form with username, etc.
		// wheras if we gather full info (username, pass) now, we can remember it and create full account after they verify their email
		const requiredFields = ["email"];
		return requiredFields;
	}

	calcRequiredRegistrationFieldsFinal() {
		// what fields we require to create the full user account
		// ATTN: note that you can't put just any arbitrary list of fields here, only certain ones are checked in processAccountAllInOneForm() above
		const requiredFields = ["email", "username", "password"];
		return requiredFields;
	}
	//---------------------------------------------------------------------------





	//---------------------------------------------------------------------------
	// this is the function called when user submits their username and password to create their account
	// AFTER they have confirmed their email address
	// it is called from the account route
	// there is very similar code elsewhere that we would like to combine
	//
	// ATTN: there is redundant code here; better would be to call the generic UseVerification process with the extra info
	// IMPORTANT: userObj may contain an email address -- if so it MUST match the one in the verification, otherwise it is an error to complain about
	// NOTE: verificationCode can optionally be an already resolved verification object
	//
	async createFullNewUserAccountForLoggedInUser(jrContext, verification, userObj) {

		// get logged in user
		const loggedInUser = await arserver.lookupLoggedInUser(jrContext);

		// log them in automatically after we create their account?
		const flagLogInUserAfterAccountCreate = true;

		// create user (passwordHashed is pre-validated)
		// ATTN: this function is typically called by caller who has already validated username, email, etc, so we COULD list these all (or *) in the preValidated list
		// but for safety we ask ValidateAndSaveNew to revalidate everything EXCEPT passwordHash which cannot be re-validated since the plaintext may be gone to the wind
		const saveFields = ["username", "email", "realName", "passwordHashed"];
		// form fields that we dont complain about finding even though they arent for the form object
		const ignoreFields = [];

		// trust the email since we just verified it
		const options = {
			flagTrustEmailChange: true,
		};
		const user = await UserModel.validateAndSaveNew(jrContext, options, true, loggedInUser, userObj, saveFields, ["passwordHashed"], ignoreFields);

		// success?
		if (!jrContext.isError()) {
			// success
			jrContext.pushSuccess("Your new account with username '" + user.username + "' has been created.");
			// mark that verification is used
			if (verification) {
				await verification.useUpAndSave(jrContext, true);
			}
			// log it
			arserver.logr(jrContext, "user.create", "created new account for " + user.getLogIdString());
			// now, if they were sessioned-in with a Login, we want to connect that to the new user
			//
			const loginId = arserver.getSessionedBridgedLoginId(jrContext);
			if (loginId) {
				await LoginModel.connectUserToLogin(jrContext, user, loginId, false);
			}
			// if successfullly created new account, should we actually log them in at this point?
			if (!jrContext.isError()) {
				if (flagLogInUserAfterAccountCreate) {
					// log them in
					await arserver.asyncManuallyLoginUserToSessionThroughPassport(jrContext, user, "postRegistration");
					if (!jrContext.isError()) {
						jrContext.pushSuccess("You have been logged in.");
					}
				}
			}
			return;
		}

		jrContext.pushError("Failed to create new user account.");
	}
	//---------------------------------------------------------------------------






	//---------------------------------------------------------------------------
	async getValidNewAccountVerificationFromRequestOrLastSession(jrContext) {
		// ATTN: In this function, we look for a (plaintext) verification code in the body of the form submitted, OR if not found, from the last session verification code checked
		// note we only look at newaccountready verifications
		let verification;

		const verificationCode = jrContext.req.body.verificationCode;
		if (!verification && verificationCode) {
			// first lookup verify code if code provided in form
			verification = await VerificationModel.mFindVerificationByCode(verificationCode);
		}
		if (!verification) {
			// not found in form, maybe there is a remembered verification id in ession regarding new account email verified, then show full
			verification = await arserver.getLastSessionedVerification(jrContext);
		}

		if (verification) {
			if (!verification.isValidNewAccountEmailReady(jrContext)) {
				// not relevant for us, so clear it
				verification = undefined;
			}
		}
		return verification;
	}
	//---------------------------------------------------------------------------









}


// export the class as the sole export
module.exports = new RegistrationAid();