Source: component/login.js

/*! meta-client/modules/component/login */
/*jslint
    browser, long
*/
/*global
*/
/**
 * login component.
 *
 * @module meta-client/modules/component/login
 */
import _ from "underscore";
import i18next from "util-web/modules/i18next";
import keyval from "util-web/modules/keyval";
import ko from "knockout";
import Logger from "js-logger";
import model from "meta-core/modules/model";
import protocol from "meta-core/modules/protocol";
import template from "./login.html";
import ui from "util-web/modules/ui";
import viewmodel from "meta-client/modules/viewmodel";
import webauthn from "meta-client/modules/webauthn";

const LOG = Logger.get("meta-client/component/login");
const TOKEN_STORAGE_KEY_PREFIX = "token-";

export default Object.freeze({
    template,
    viewModel: {
        createViewModel: function ({
            client,
            clientName,
            clientVersion,
            customClasses = null,
            editUserEnable = false,
            help = null,
            info = null,
            isActive,
            login,
            logout,
            onAuth,
            onConnectFailed,
            otpRequired = false,
            otpTypes = [],
            passwordRegex = null,
            totpIssuer = null,
            webAuthnEnable = false
        }) {
            const connect = function () {
                if (client.isConnected()) {
                    return Promise.resolve(undefined);
                }
                return client.connect();
            };
            const tokenStorageKey = TOKEN_STORAGE_KEY_PREFIX + clientName;
            const deleteToken = () => keyval.delete(tokenStorageKey).catch(
                (exc) => LOG.warn("deleteToken failed", exc)
            ).then(() => vm.hasUserFocus(true));
            const loadStoredToken = function () {
                return keyval.get(tokenStorageKey).then(function (token) {
                    if (token) {
                        return vm.loginInternal({token});
                    }
                    vm.hasUserFocus(true);
                    return Promise.resolve(undefined);
                }, function (exc) {
                    LOG.warn("loadStoredToken failed", exc);
                }).then(function () {
                    vm.isLoadingToken(false);
                });
            };
            const vm = {};

            if (otpRequired && _.isEmpty(otpTypes)) {
                throw new Error("otpTypes required");
            }
            if (otpTypes.includes(model.metaUserOtpType.TOTP) && _.isEmpty(totpIssuer)) {
                throw new Error("totpIssuer required");
            }

            vm.authRef = protocol.REST_PATH + "/" + protocol.getWebhookAuthPath("exampleAuthRef");
            vm.customClasses = customClasses;
            vm.isActive = isActive;
            vm.metaUserOtpType = model.metaUserOtpType;
            vm.otpTypeNone = (
                otpRequired
                ? undefined
                : i18next.pureComputedT("no_otp")
            );
            vm.otpTypes = otpTypes;
            vm.otpTypeOptions = Object.keys(model.metaUserOtpType).map(function (key) {
                if (otpTypes.includes(key) === false) {
                    return;
                }
                return {name: i18next.computedT("otp_type_" + key), type: key};
            }).filter(_.isObject);

            vm.currentLocale = i18next.currentLocale;
            vm.changeLocale = (locale) => i18next.setLocale(locale);

            vm.user = ko.observable();
            vm.hasUserFocus = ko.observable(false);
            vm.password = ko.observable();
            vm.unmaskPasswords = ko.observable(false);
            vm.editUserEnable = editUserEnable;
            vm.webAuthnEnable = webAuthnEnable;
            vm.enableHelp = _.isFunction(help);
            vm.info = (
                _.isEmpty(info)
                ? undefined
                : info
            );

            vm.connectFailed = ko.observable(false);
            vm.hasOtpFocus = ko.observable(false);
            vm.isAuthActive = ko.observable(false);
            vm.isAuthValid = ko.observable(false);
            vm.isLoggingIn = ko.observable(false);
            vm.loginFailed = ko.observable(false);
            vm.onAuthFailed = ko.observable(false);
            vm.otp = ko.observable();
            vm.otp.subscribe(() => vm.isAuthValid(true));

            vm.togglePasswordMasking = () => vm.unmaskPasswords(!vm.unmaskPasswords());

            vm.onAuth = function () {
                if (vm.isEditingUser()) {
                    return vm.loadUser();
                }
                return client.authToken(protocol.authTokenRequest()).then(function (reply) {
                    if (reply) {
                        return keyval.set(tokenStorageKey, reply.token);
                    }
                }).catch(
                    (exc) => LOG.warn("onAuth authToken failed", exc)
                ).then(function () {
                    vm.onAuthFailed(false);
                    if (_.isFunction(onAuth)) {
                        onAuth();
                    }
                    return Promise.resolve(undefined);
                }).catch(function (exc) {
                    vm.onAuthFailed(true);
                    LOG.error("onAuth call failed", exc);
                });
            };

            vm.loginInternal = function ({
                password = null,
                token = null,
                user = null
            }) {
                vm.isLoggingIn(true);
                return connect().then(function () {
                    vm.connectFailed(false);
                    return client.login(protocol.loginRequest({
                        client: clientName,
                        locale: i18next.currentLocale(),
                        password,
                        token,
                        user,
                        version: clientVersion
                    }));
                }, function (exc) {
                    LOG.warn("connect failed", exc);
                    if (_.isFunction(onConnectFailed)) {
                        onConnectFailed();
                    }
                    return Promise.reject(exc);
                }).then(function (reply) {
                    vm.loginFailed(false);
                    if (reply.isAuth) {
                        return vm.onAuth();
                    }
                    vm.isAuthActive(true);
                    vm.hasOtpFocus(true);
                    if (reply.webAuthnAssertionRequest) {
                        return vm.authInternal(reply.webAuthnAssertionRequest);
                    }
                    return Promise.resolve(undefined);
                }, function (exc) {
                    LOG.warn("loginInternal failed", exc);
                    if (exc && _.has(exc, protocol.REPLY_TYPE_PROPERTY)) {
                        if (_.isEmpty(token) === false) {
                            return deleteToken();
                        }
                        vm.loginFailed(true);
                        vm.hasUserFocus(true);
                    } else {
                        vm.connectFailed(true);
                    }
                }).then(function () {
                    vm.password(undefined);
                    vm.isLoggingIn(false);
                });
            };

            vm.login = function (form) {
                if (ui.validateForm(form) === false) {
                    return;
                }
                const user = vm.user();
                LOG.info(`login, user=${user}`);
                return vm.loginInternal({
                    password: vm.password(),
                    user
                }).then(function () {
                    ui.resetForm(form);
                });
            };

            if (ko.isWritableObservable(login)) {
                login.subscribe(function (doLogin) {
                    if (doLogin) {
                        vm.login();
                        login(false);
                    }
                });
            }

            vm.isWebAuthnActive = ko.observable(false);
            vm.webAuthnFailed = ko.observable(false);

            vm.authInternal = function (webAuthnAssertionRequest = null) {
                let webAuthnAssertionPromise = Promise.resolve(undefined);
                if (webAuthnAssertionRequest) {
                    vm.isWebAuthnActive(true);
                    webAuthnAssertionPromise = webauthn.getAssertion(webAuthnAssertionRequest.assertion);
                }
                LOG.debug(`authInternal, clientId=${client.clientId()}, user=${client.user()}`);
                return webAuthnAssertionPromise.then(function (webAuthnResponse) {
                    const request = (
                        webAuthnResponse
                        ? protocol.authRequest({
                            webAuthnAssertionReply: protocol.authRequestWebAuthnAssertionReply({credential: webAuthnResponse})
                        })
                        : protocol.authRequest({otp: vm.otp()})
                    );
                    return client.auth(protocol.authRequest(request));
                }, function (exc) {
                    LOG.warn("authInternal webAuthn failed", exc);
                    vm.webAuthnFailed(true);
                }).then(function () {
                    vm.otp(undefined);
                    vm.isWebAuthnActive(false);
                    vm.webAuthnFailed(false);
                    vm.isAuthValid(true);
                    vm.isAuthActive(false);
                    return vm.onAuth();
                }, function (exc) {
                    LOG.warn("authInternal failed", exc);
                    vm.isAuthValid(false);
                });
            };

            vm.isInAuth = ko.observable(false);

            vm.auth = function (form) {
                if (ui.validateForm(form) === false) {
                    return;
                }
                if (client.isConnected() === false || Boolean(client.clientId()) === false) {
                    vm.isAuthActive(false);
                    return;
                }
                LOG.debug("auth");
                vm.isInAuth(true);
                return vm.authInternal().then(function () {
                    vm.isInAuth(false);
                    if (vm.isAuthValid()) {
                        ui.resetForm(form);
                    }
                });
            };

            vm.cancelAuth = function () {
                ui.resetForm(document.getElementById("auth"));
                vm.otp(undefined);
                webauthn.abortAssertion();
                return vm.logout();
            };

            vm.logout = function () {
                LOG.debug("logout");
                vm.user(undefined);
                vm.password(undefined);
                vm.isLoggingIn(false);
                vm.isAuthActive(false);
                vm.isAuthValid(true);
                vm.isWebAuthnActive(false);
                vm.saveUserFailed(false);
                vm.saveUserVerifyFailed(false);
                vm.webAuthnFailed(false);
                return client.logout(protocol.logoutRequest()).catch(
                    (exc) => LOG.warn("logout failed", exc)
                ).then(deleteToken);
            };

            if (ko.isWritableObservable(logout)) {
                logout.subscribe(function (doLogout) {
                    if (doLogout) {
                        vm.logout();
                        logout(false);
                    }
                });
            }

            vm.isEditingUser = ko.observable(false);
            vm.editedUser = ko.observable();
            vm.newPassword = ko.observable();
            vm.newPasswordConfirm = ko.observable();
            vm.isNewPasswordValid = viewmodel.newPasswordValidator({
                passwordConfirm: vm.newPasswordConfirm,
                passwordNew: vm.newPassword,
                regexString: passwordRegex,
                unmask: vm.unmaskPasswords
            });
            vm.newOtpType = ko.observable();
            vm.newOtpEmail = ko.observable();
            vm.newOtpPhone = ko.observable();
            vm.hasTotp = ko.observable(false);
            vm.doGenerateTotp = ko.observable(false);
            vm.newWebhook = ko.observable();
            vm.isNewOtpValid = ko.pureComputed(function () {
                const type = vm.newOtpType();
                if (type) {
                    switch (type) {
                    case model.metaUserOtpType.EMAIL:
                        return _.isEmpty(vm.newOtpEmail()) === false;
                    case model.metaUserOtpType.PHONE:
                        return _.isEmpty(vm.newOtpPhone()) === false;
                    case model.metaUserOtpType.WEBHOOK:
                        return _.isEmpty(vm.newWebhook()) === false;
                    }
                }
                return true;
            });
            vm.webAuthnCredentials = ko.observableArray();
            vm.newWebAuthnDisplayName = ko.observable();
            vm.isSavingUser = ko.observable(false);
            vm.isRegisteringWebAuthn = ko.observable(false);
            vm.hasSavedUser = ko.observable(false);
            vm.saveUserFailed = ko.observable(false);
            vm.saveUserVerifyFailed = ko.observable(false);

            vm.editUser = function () {
                LOG.debug("editUser");
                vm.isEditingUser(true);
                vm.hasUserFocus(true);
            };

            vm.cancelEditUser = function () {
                ui.resetForm(document.getElementById("saveUser"));
                vm.editedUser(undefined);
                vm.newPassword(undefined);
                vm.newPasswordConfirm(undefined);
                vm.newOtpType(undefined);
                vm.newOtpEmail(undefined);
                vm.newOtpPhone(undefined);
                vm.hasTotp(false);
                vm.doGenerateTotp(false);
                vm.newWebhook(undefined);
                vm.webAuthnCredentials.removeAll();
                vm.newWebAuthnDisplayName(undefined);
                vm.isEditingUser(false);
                return vm.logout();
            };

            vm.loadUser = function () {
                LOG.debug("loadUser");
                return client.userSearch(protocol.userSearchRequest({
                    limit: 1,
                    query: client.user()
                })).then(function (reply) {
                    const user = _.head(reply.users);
                    if (Boolean(user) === false) {
                        return vm.cancelEditUser();
                    }
                    vm.editedUser(user);
                    if (user.otp) {
                        vm.newOtpType(user.otp.type);
                        vm.newOtpEmail(user.otp.email);
                        vm.newOtpPhone(user.otp.phone);
                        vm.hasTotp(model.metaUserOtpType.TOTP === user.otp.type && Boolean(user.otp.totp));
                        vm.newWebhook(user.otp.webhook);
                    }
                    if (user.webAuthn) {
                        vm.webAuthnCredentials(user.webAuthn.credentials.map(function (credential) {
                            return Object.assign({
                                remove: ko.observable(false),
                                sanitizedDisplayName: credential.displayName.replace(/\s/g, "")
                            }, credential);
                        }));
                    }
                });
            };

            vm.onVerify = ko.observable();
            vm.onVerifyDone = ko.observable();

            vm.getSanitizedWebAuthn = function (webAuthn = null) {
                if (_.isObject(webAuthn)) {
                    return Object.assign(
                        {},
                        webAuthn,
                        {
                            credentials: vm.webAuthnCredentials().filter((credential) => credential.remove() === false).map(function (credential) {
                                delete credential.remove;
                                delete credential.sanitizedDisplayName;
                                return credential;
                            })
                        }
                    );
                }
                return webAuthn;
            };

            vm.saveUser = function (form) {
                if (ui.validateForm(form) === false) {
                    return;
                }
                const user = vm.editedUser();
                const otpType = vm.newOtpType();
                const newPassword = vm.newPassword();
                const updateUser = model.metaUser(Object.assign({}, user, {
                    otp: (
                        otpType
                        ? model.metaUserOtp({
                            email: vm.newOtpEmail(),
                            phone: vm.newOtpPhone(),
                            totp: (
                                vm.doGenerateTotp()
                                ? null
                                : _.get(user, ["otp", "totp"])
                            ),
                            type: otpType,
                            webhook: vm.newWebhook()
                        })
                        : null
                    ),
                    password: (
                        _.isEmpty(newPassword)
                        ? user.password
                        : newPassword
                    ),
                    webAuthn: vm.getSanitizedWebAuthn(user.webAuthn)
                }));
                LOG.debug("saveUser", updateUser);
                const webAuthnRequest = (
                    _.isEmpty(vm.newWebAuthnDisplayName()) === false
                    ? protocol.userWriteRequestWebAuthnRequest({displayName: vm.newWebAuthnDisplayName()})
                    : null
                );
                vm.isSavingUser(true);
                vm.saveUserFailed(false);
                return client.userWrite(protocol.userWriteRequest({
                    action: protocol.WRITE_ACTION.UPDATE,
                    locale: i18next.currentLocale(),
                    user: updateUser,
                    webAuthnRequest
                })).then(function (reply) {
                    if (reply.verifyOtp) {
                        vm.saveUserVerifyFailed(false);
                        return new Promise(function (resolve, reject) {
                            const verifySub = vm.onVerifyDone.subscribe(function (result) {
                                verifySub.dispose();
                                const otp = _.get(result, "otp");
                                LOG.debug(`saveUser onVerifyDone, otp=${otp}`);
                                if (otp) {
                                    resolve(otp);
                                } else {
                                    reject();
                                }
                            });
                            vm.onVerify({
                                totpIssuer,
                                totpKey: reply.totpKey,
                                user: reply.user.name
                            });
                        }).then(function (otp) {
                            return client.userWrite(protocol.userWriteRequest({ // OTP second request flow
                                action: protocol.WRITE_ACTION.UPDATE,
                                locale: i18next.currentLocale(),
                                otp,
                                user: updateUser
                            }));
                        });
                    }
                    if (reply.webAuthnRequest) {
                        vm.isRegisteringWebAuthn(true);
                        return webauthn.createCredential(reply.webAuthnRequest.credentialOptions).then(function (response) {
                            return client.userWrite(protocol.userWriteRequest({
                                action: protocol.WRITE_ACTION.UPDATE,
                                locale: i18next.currentLocale(),
                                user: updateUser,
                                webAuthnReply: protocol.userWriteRequestWebAuthnReply({credential: response})
                            }));
                        });
                    }
                }).then(function () {
                    vm.hasSavedUser(true);
                    ui.resetForm(form);
                    return vm.cancelEditUser();
                }).catch(function (exc) {
                    LOG.warn("saveUser failed", exc);
                    if (protocol.ERROR_TYPE.AUTH === _.get(exc, "type")) {
                        vm.saveUserVerifyFailed(true);
                    } else {
                        vm.saveUserFailed(true);
                    }
                }).then(function () {
                    vm.isRegisteringWebAuthn(false);
                    vm.isSavingUser(false);
                });
            };

            vm.isRecoverUserActive = ko.observable(false);
            vm.hasRecoverUserEmailFocus = ko.observable(false);
            vm.recoverUserEmail = ko.observable();
            vm.recoverUserPhone = ko.observable();
            vm.isRecoverUserValid = ko.pureComputed(function () {
                const email = vm.recoverUserEmail();
                const phone = vm.recoverUserPhone();
                return _.isEmpty(email) === false || _.isEmpty(phone) === false;
            });
            vm.recoverUserSuccess = ko.observable(false);

            vm.activateRecoverUser = function () {
                vm.isResetPasswordActive(false);
                vm.isRecoverUserActive(true);
                vm.hasRecoverUserEmailFocus(true);
            };

            vm.cancelRecoverUser = function () {
                vm.isRecoverUserActive(false);
                vm.recoverUserEmail(undefined);
                vm.recoverUserPhone(undefined);
            };

            vm.recoverUser = function (form) {
                if (ui.validateForm(form) === false) {
                    return;
                }
                LOG.debug("recoverUser");
                return connect().then(function () {
                    return client.recoverUser(protocol.recoverUserRequest({
                        email: vm.recoverUserEmail(),
                        locale: i18next.currentLocale(),
                        phone: vm.recoverUserPhone()
                    }));
                }).then(function () {
                    ui.resetForm(form);
                    vm.cancelRecoverUser();
                    vm.recoverUserSuccess(true);
                });
            };

            vm.hasResetPasswordOtpFocus = ko.observable(false);
            vm.hasResetPasswordUserFocus = ko.observable(false);
            vm.hasSentResetPasswordOtp = ko.observable(false);
            vm.isResetPasswordActive = ko.observable(false);
            vm.resetPasswordFailed = ko.observable(false);
            vm.resetPasswordNew = ko.observable();
            vm.resetPasswordNewConfirm = ko.observable();
            vm.resetPasswordOtp = ko.observable();
            vm.resetPasswordSuccess = ko.observable(false);
            vm.resetPasswordUser = ko.observable();

            vm.isResetPasswordNewValid = viewmodel.newPasswordValidator({
                passwordConfirm: vm.resetPasswordNewConfirm,
                passwordNew: vm.resetPasswordNew,
                regexString: passwordRegex,
                unmask: vm.unmaskPasswords
            });

            vm.activateResetPassword = function () {
                vm.isRecoverUserActive(false);
                vm.isResetPasswordActive(true);
                vm.hasResetPasswordUserFocus(true);
            };

            vm.cancelResetPassword = function () {
                vm.hasSentResetPasswordOtp(false);
                vm.isResetPasswordActive(false);
                vm.resetPasswordFailed(false);
                vm.resetPasswordNew(undefined);
                vm.resetPasswordNewConfirm(undefined);
                vm.resetPasswordOtp(undefined);
                vm.resetPasswordUser(undefined);
            };

            vm.resetPasswordStart = function (form) {
                if (ui.validateForm(form) === false) {
                    return;
                }
                const user = vm.resetPasswordUser();
                LOG.info(`resetPasswordStart, user=${user}`);
                return connect().then(function () {
                    return client.resetPassword(protocol.resetPasswordRequest({
                        locale: i18next.currentLocale(),
                        user
                    }));
                }).then(function () {
                    vm.resetPasswordFailed(false);
                    vm.hasSentResetPasswordOtp(true);
                    vm.hasResetPasswordOtpFocus(true);
                    ui.resetForm(form);
                });
            };

            vm.resetPasswordVerify = function (form) {
                if (ui.validateForm(form) === false) {
                    return;
                }
                const user = vm.resetPasswordUser();
                LOG.debug(`resetPasswordVerify, user=${user}`);
                return client.resetPassword(protocol.resetPasswordRequest({
                    locale: i18next.currentLocale(),
                    newPassword: vm.resetPasswordNew(),
                    otp: vm.resetPasswordOtp(),
                    user
                })).then(function (reply) {
                    ui.resetForm(form);
                    vm.cancelResetPassword();
                    if (reply.success) {
                        vm.resetPasswordSuccess(true);
                    } else {
                        vm.resetPasswordFailed(true);
                    }
                });
            };

            vm.activateHelp = function () {
                if (vm.enableHelp) {
                    help();
                }
            };

            vm.isLoadingToken = ko.observable(true);
            loadStoredToken();

            return Object.freeze(vm);
        }
    }
});