meteor/meteor

View on GitHub
packages/accounts-ui-unstyled/login_buttons_dropdown.js

Summary

Maintainability
D
2 days
Test Coverage
import {passwordlessSignupFields, passwordSignupFields} from './accounts_ui.js';
import {
  displayName,
  getLoginServices,
  hasPasswordService,
  hasPasswordlessService,
  validateUsername,
  validateEmail,
  validatePassword,
} from './login_buttons.js';

// for convenience
const loginButtonsSession = Accounts._loginButtonsSession;

//
// helpers
//

const elementValueById = id => {
  const element = document.getElementById(id);
  if (!element)
    return null;
  else
    return element.value;
};

const trimmedElementValueById = id => {
  const element = document.getElementById(id);
  if (!element)
    return null;
  else
    return element.value.replace(/^\s*|\s*$/g, ""); // trim() doesn't work on IE8;
};

const loginOrSignup = () => {
  if (loginButtonsSession.get('inSignupFlow'))
    signup();
  else
    login();
};

const loginOrSignupPasswordless = () => {
  loginButtonsSession.resetMessages();

  if (loginButtonsSession.get('inPasswordlessConfirmation')) {
    const token = trimmedElementValueById('login-code-passwordless');
    Meteor.passwordlessLoginWithToken({ email: loginButtonsSession.get('passwordlessCodeEmail') }, token, (error) => {
      if (error) {
        loginButtonsSession.errorMessage(error.reason || "Unknown error");
      } else {
        loginButtonsSession.set('inPasswordlessConfirmation', false);
        loginButtonsSession.set('passwordlessCodeEmail', null);
      }
    });

    return;
  }

  const email = trimmedElementValueById('login-email-passwordless');
  const username = trimmedElementValueById('login-username-passwordless');

  if (!email.includes('@')) {
    loginButtonsSession.errorMessage("Invalid email");
    return;
  }
  if (Accounts._options.forbidClientAccountCreation) {
    loginButtonsSession.errorMessage("Action not allowed");
    return;
  }

  if (username !== null && !validateUsername(username)) {
    return;
  }

  Accounts.requestLoginTokenForUser({ selector: email, userData: { email, username } }, (error, result) => {
    if (error) {
      loginButtonsSession.errorMessage(error.reason || "Unknown error");
    } else {
      loginButtonsSession.set('inPasswordlessConfirmation', true);
      loginButtonsSession.set('inSignupFlow', false);
      loginButtonsSession.set('passwordlessCodeEmail', result?.selector?.email);
    }
  });
}

const login = () => {
  loginButtonsSession.resetMessages();

  const username = trimmedElementValueById('login-username');
  const email = trimmedElementValueById('login-email');
  const usernameOrEmail = trimmedElementValueById('login-username-or-email');
  // notably not trimmed. a password could (?) start or end with a space
  const password = elementValueById('login-password');

  let loginSelector;
  if (username !== null) {
    if (!validateUsername(username))
      return;
    else
      loginSelector = {username: username};
  } else if (email !== null) {
    if (!validateEmail(email))
      return;
    else
      loginSelector = {email: email};
  } else if (usernameOrEmail !== null) {
    // XXX not sure how we should validate this. but this seems good enough (for now),
    // since an email must have at least 3 characters anyways
    if (!validateUsername(usernameOrEmail))
      return;
    else
      loginSelector = usernameOrEmail;
  } else {
    throw new Error("Unexpected -- no element to use as a login user selector");
  }

  Meteor.loginWithPassword(loginSelector, password, (error, result) => {
    if (error) {
      loginButtonsSession.errorMessage(error.reason || "Unknown error");
    } else {
      loginButtonsSession.closeDropdown();
    }
  });
};

const signup = () => {
  loginButtonsSession.resetMessages();

  const options = {}; // to be passed to Accounts.createUser

  const username = trimmedElementValueById('login-username');
  if (username !== null) {
    if (!validateUsername(username))
      return;
    else
      options.username = username;
  }

  const email = trimmedElementValueById('login-email');
  if (email !== null) {
    if (!validateEmail(email))
      return;
    else
      options.email = email;
  }

  // notably not trimmed. a password could (?) start or end with a space
  const password = elementValueById('login-password');
  if (!validatePassword(password))
    return;
  else
    options.password = password;

  if (!matchPasswordAgainIfPresent())
    return;

  Accounts.createUser(options, error => {
    if (error) {
      loginButtonsSession.errorMessage(error.reason || "Unknown error");
    } else {
      loginButtonsSession.closeDropdown();
    }
  });
};

const forgotPassword = () => {
  loginButtonsSession.resetMessages();

  const email = trimmedElementValueById("forgot-password-email");
  if (email.includes('@')) {
    Accounts.forgotPassword({email: email}, error => {
      if (error)
        loginButtonsSession.errorMessage(error.reason || "Unknown error");
      else
        loginButtonsSession.infoMessage("Email sent");
    });
  } else {
    loginButtonsSession.errorMessage("Invalid email");
  }
};

const changePassword = () => {
  loginButtonsSession.resetMessages();

  // notably not trimmed. a password could (?) start or end with a space
  const oldPassword = elementValueById('login-old-password');

  // notably not trimmed. a password could (?) start or end with a space
  const password = elementValueById('login-password');
  if (!validatePassword(password))
    return;

  if (!matchPasswordAgainIfPresent())
    return;

  Accounts.changePassword(oldPassword, password, error => {
    if (error) {
      loginButtonsSession.errorMessage(error.reason || "Unknown error");
    } else {
      loginButtonsSession.set('inChangePasswordFlow', false);
      loginButtonsSession.set('inMessageOnlyFlow', true);
      loginButtonsSession.infoMessage("Password changed");
    }
  });
};

const matchPasswordAgainIfPresent = () => {
  // notably not trimmed. a password could (?) start or end with a space
  const passwordAgain = elementValueById('login-password-again');
  if (passwordAgain !== null) {
    // notably not trimmed. a password could (?) start or end with a space
    const password = elementValueById('login-password');
    if (password !== passwordAgain) {
      loginButtonsSession.errorMessage("Passwords don't match");
      return false;
    }
  }
  return true;
};

// Utility containment function that works with both arrays and single values
const isInPasswordSignupFields = (fieldOrFields) => {
  const signupFields = passwordSignupFields();

  if (Array.isArray(fieldOrFields)) {
    return signupFields.reduce(
      (prev, field) => prev && fieldOrFields.includes(field),
      true,
    )
  }

  return signupFields.includes(fieldOrFields);
};

const isInPasswordlessSignupFields = (fieldOrFields) => {
  const signupFields = passwordlessSignupFields();

  if (Array.isArray(fieldOrFields)) {
    return signupFields.reduce(
      (prev, field) => prev && fieldOrFields.includes(field),
      true,
    )
  }

  return signupFields.includes(fieldOrFields);
}

// events shared between loginButtonsLoggedOutDropdown and
// loginButtonsLoggedInDropdown
Template.loginButtons.events({
  'click #login-name-link, click #login-sign-in-link': () =>
    loginButtonsSession.set('dropdownVisible', true),
  'click .login-close-text': loginButtonsSession.closeDropdown,
});


//
// loginButtonsLoggedInDropdown template and related
//

Template._loginButtonsLoggedInDropdown.events({
  'click #login-buttons-open-change-password': () => {
    loginButtonsSession.resetMessages();
    loginButtonsSession.set('inChangePasswordFlow', true);
  }
});

Template._loginButtonsLoggedInDropdown.helpers({
  displayName,
  inChangePasswordFlow: () => loginButtonsSession.get('inChangePasswordFlow'),
  inMessageOnlyFlow: () => loginButtonsSession.get('inMessageOnlyFlow'),
  dropdownVisible: () => loginButtonsSession.get('dropdownVisible'),
});

Template._loginButtonsLoggedInDropdownActions.helpers({
  allowChangingPassword: () => {
    // it would be more correct to check whether the user has a password set,
    // but in order to do that we'd have to send more data down to the client,
    // and it'd be preferable not to send down the entire service.password document.
    //
    // instead we use the heuristic: if the user has a username or email set.
    if (!Package['accounts-password']) return false;
    const user = Meteor.user();
    return user.username || (user.emails && user.emails[0] && user.emails[0].address);
  }
});


//
// loginButtonsLoggedOutDropdown template and related
//

Template._loginButtonsLoggedOutDropdown.events({
  'click #login-buttons-password': event => {
    event.preventDefault();
    loginOrSignup();
  },

  'click #login-buttons-passwordless': event => {
    event.preventDefault();
    loginOrSignupPasswordless();
  },

  'keypress #forgot-password-email': event => {
    if (event.keyCode === 13) {
      event.preventDefault();
      forgotPassword();
    }
  },

  'click #login-buttons-forgot-password': forgotPassword,

  'click #signup-link': () => {
    loginButtonsSession.resetMessages();

    // store values of fields before swtiching to the signup form
    const username = trimmedElementValueById('login-username');
    const email = trimmedElementValueById('login-email');
    const usernameOrEmail = trimmedElementValueById('login-username-or-email');
    // notably not trimmed. a password could (?) start or end with a space
    const password = elementValueById('login-password');

    loginButtonsSession.set('inSignupFlow', true);
    loginButtonsSession.set('inForgotPasswordFlow', false);
    // force the ui to update so that we have the approprate fields to fill in
    Tracker.flush();

    // update new fields with appropriate defaults
    if (username !== null)
      document.getElementById('login-username').value = username;
    else if (email !== null)
      document.getElementById('login-email').value = email;
    else if (usernameOrEmail !== null)
      if (!usernameOrEmail.includes('@'))
        document.getElementById('login-username').value = usernameOrEmail;
    else
      document.getElementById('login-email').value = usernameOrEmail;

    if (password !== null)
      document.getElementById('login-password').value = password;

    // Force redrawing the `login-dropdown-list` element because of
    // a bizarre Chrome bug in which part of the DIV is not redrawn
    // in case you had tried to unsuccessfully log in before
    // switching to the signup form.
    //
    // Found tip on how to force a redraw on
    // http://stackoverflow.com/questions/3485365/how-can-i-force-webkit-to-redraw-repaint-to-propagate-style-changes/3485654#3485654
    const redraw = document.getElementById('login-dropdown-list');
    redraw.style.display = 'none';
    redraw.offsetHeight; // it seems that this line does nothing but is necessary for the redraw to work
    redraw.style.display = 'block';
  },
  'click #forgot-password-link': () => {
    loginButtonsSession.resetMessages();

    // store values of fields before swtiching to the signup form
    const email = trimmedElementValueById('login-email');
    const usernameOrEmail = trimmedElementValueById('login-username-or-email');

    loginButtonsSession.set('inSignupFlow', false);
    loginButtonsSession.set('inForgotPasswordFlow', true);
    // force the ui to update so that we have the approprate fields to fill in
    Tracker.flush();

    // update new fields with appropriate defaults
    if (email !== null)
      document.getElementById('forgot-password-email').value = email;
    else if (usernameOrEmail !== null)
      if (usernameOrEmail.includes('@'))
        document.getElementById('forgot-password-email').value = usernameOrEmail;

  },
  'click #resend-passwordless-code': () => {
    loginButtonsSession.set('inPasswordlessConfirmation', false);
    loginButtonsSession.set('passwordlessCodeEmail', null);
  },
  'click #back-to-login-link': () => {
    loginButtonsSession.resetMessages();

    const username = trimmedElementValueById('login-username');
    const email = trimmedElementValueById('login-email')
          || trimmedElementValueById('forgot-password-email'); // Ughh. Standardize on names?
    // notably not trimmed. a password could (?) start or end with a space
    const password = elementValueById('login-password');

    loginButtonsSession.set('inSignupFlow', false);
    loginButtonsSession.set('inForgotPasswordFlow', false);
    // force the ui to update so that we have the approprate fields to fill in
    Tracker.flush();

    if (document.getElementById('login-username') && username !== null)
      document.getElementById('login-username').value = username;
    if (document.getElementById('login-email') && email !== null)
      document.getElementById('login-email').value = email;

    const usernameOrEmailInput = document.getElementById('login-username-or-email');
    if (usernameOrEmailInput) {
      if (email !== null)
        usernameOrEmailInput.value = email;
      if (username !== null)
        usernameOrEmailInput.value = username;
    }

    if (password !== null)
      document.getElementById('login-password').value = password;
  },
});

Template._loginButtonsLoggedOutDropdown.helpers({
  // additional classes that can be helpful in styling the dropdown
  additionalClasses: () => {
    if (!hasPasswordService() || !hasPasswordlessService()) {
      return false;
    } else {
      if (loginButtonsSession.get('inSignupFlow')) {
        return 'login-form-create-account';
      } else if (loginButtonsSession.get('inForgotPasswordFlow')) {
        return 'login-form-forgot-password';
      } else {
        return 'login-form-sign-in';
      }
    }
  },

  dropdownVisible: () => loginButtonsSession.get('dropdownVisible'),

  hasPasswordService,
  hasPasswordlessService,
});

// return all login services, with password last
Template._loginButtonsLoggedOutAllServices.helpers({
  services: getLoginServices,
  isPasswordService: function () {
    return this.name === 'password';
  },
  isPasswordlessService: function () {
    return this.name === 'passwordless';
  },
  hasOtherServices: () => {
    let count = 0;
    if (hasPasswordlessService()) count++;
    if (hasPasswordService()) count++;
    return getLoginServices().length > count;
  },
  displaySeparatorForPasswordless: () => {
    return hasPasswordService() || getLoginServices().length > 1;
  },
  isInternalService: function () {
    return this.name === 'password' || this.name === 'passwordless'
  },
  hasInternalService: () => hasPasswordService() || hasPasswordlessService(),
  hasPasswordService,
  hasPasswordlessService,
});

Template._loginButtonsLoggedOutPasswordlessService.helpers({
  fields: () => [
    {
      fieldName: 'email-passwordless',
      fieldLabel: 'Email',
      autocomplete: 'email',
      inputType: 'email',
      visible: () => !loginButtonsSession.get('inPasswordlessConfirmation'),
    },
    {
      fieldName: 'username-passwordless',
      fieldLabel: 'Username',
      autocomplete: 'username',
      inputType: 'text',
      visible: () => isInPasswordlessSignupFields('USERNAME_AND_EMAIL') && loginButtonsSession.get('inSignupFlow')
    },
    {
      fieldName: 'code-passwordless',
      fieldLabel: 'Code',
      inputType: 'text',
      visible: () => loginButtonsSession.get('inPasswordlessConfirmation')
    }
  ],
  inForgotPasswordFlow: () => loginButtonsSession.get('inForgotPasswordFlow'),
  inPasswordlessConfirmation: () => loginButtonsSession.get('inPasswordlessConfirmation'),

  inLoginFlow: () =>
    !loginButtonsSession.get('inSignupFlow') &&
    !loginButtonsSession.get('inForgotPasswordFlow'),

  inSignupFlow: () => loginButtonsSession.get('inSignupFlow'),

  showCreateAccountLink: () => !Accounts._options.forbidClientAccountCreation,
})

Template._loginButtonsLoggedOutPasswordService.helpers({
  fields: () => {
    const loginFields = [
      {fieldName: 'username-or-email', fieldLabel: 'Username or Email',
        autocomplete: 'username email',
        visible: () => isInPasswordSignupFields(
          ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL"]
        ),
      },
      {fieldName: 'username', fieldLabel: 'Username', autocomplete: 'username',
        visible: () => isInPasswordSignupFields("USERNAME_ONLY"),
      },
      {fieldName: 'email', fieldLabel: 'Email', inputType: 'email',
        autocomplete: 'email',
        visible: () => isInPasswordSignupFields("EMAIL_ONLY"),
      },
      {fieldName: 'password', fieldLabel: 'Password', inputType: 'password',
        autocomplete: 'current-password',
        visible: () => true,
      }
    ];

    const signupFields = [
      {fieldName: 'username', fieldLabel: 'Username', autocomplete: 'username',
        visible: () => isInPasswordSignupFields([
          "USERNAME_AND_EMAIL",
          "USERNAME_AND_OPTIONAL_EMAIL",
          "USERNAME_ONLY",
        ]),
      },
      {fieldName: 'email', fieldLabel: 'Email', inputType: 'email',
        autocomplete: 'email',
        visible: () => isInPasswordSignupFields(
          ["USERNAME_AND_EMAIL", "EMAIL_ONLY"]
        ),
      },
      {fieldName: 'email', fieldLabel: 'Email (optional)', inputType: 'email',
        autocomplete: 'email',
        visible: () => isInPasswordSignupFields("USERNAME_AND_OPTIONAL_EMAIL"),
      },
      {fieldName: 'password', fieldLabel: 'Password', inputType: 'password',
        autocomplete: 'new-password',
        visible: () => true,
      },
      {fieldName: 'password-again', fieldLabel: 'Password (again)',
       inputType: 'password', autocomplete: 'new-password',
        // No need to make users double-enter their password if
        // they'll necessarily have an email set, since they can use
        // the "forgot password" flow.
        visible: () => isInPasswordSignupFields(
          ["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"]
        ),
      },
    ];

    return loginButtonsSession.get('inSignupFlow') ? signupFields : loginFields;
  },

  inForgotPasswordFlow: () => loginButtonsSession.get('inForgotPasswordFlow'),

  inLoginFlow: () =>
    !loginButtonsSession.get('inSignupFlow') &&
    !loginButtonsSession.get('inForgotPasswordFlow'),

  inSignupFlow: () => loginButtonsSession.get('inSignupFlow'),

  showCreateAccountLink: () => !Accounts._options.forbidClientAccountCreation,

  showForgotPasswordLink: () => isInPasswordSignupFields(
    ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "EMAIL_ONLY"]
  ),
});

Template._loginButtonsFormField.helpers({
  inputType: function () {
    return this.inputType || "text"
  }
});


//
// loginButtonsChangePassword template
//

Template._loginButtonsChangePassword.events({
  'keypress #login-old-password, keypress #login-password, keypress #login-password-again': event => {
    if (event.keyCode === 13)
      changePassword();
  },
  'click #login-buttons-do-change-password': changePassword,
});

Template._loginButtonsChangePassword.helpers({
  fields: () => {
    const { username, emails } = Meteor.user()
    let email;
    if (emails) {
      email = emails[0].address;
    }
    return [
      // The username and email fields are included here to address an
      // accessibility warning in Chrome, but the fields don't actually display.
      // The warning states that there should be an optionally hidden
      // username/email field on password forms.
      // XXX I think we should not use a CSS class here because this is the
      // `unstyled` package. So instead we apply an inline style.
      {fieldName: 'username', fieldLabel: 'Username', autocomplete: 'username',
        fieldStyle: 'display: none;', fieldValue: username,
        visible: () => isInPasswordSignupFields([
          "USERNAME_AND_EMAIL",
          "USERNAME_AND_OPTIONAL_EMAIL",
          "USERNAME_ONLY",
        ]),
      },
      {fieldName: 'email', fieldLabel: 'Email', inputType: 'email',
        autocomplete: 'email', fieldStyle: 'display: none;', fieldValue: email,
        visible: () => isInPasswordSignupFields(
          ["USERNAME_AND_EMAIL", "EMAIL_ONLY"]
        ),
      },
      {fieldName: 'old-password', fieldLabel: 'Current Password', inputType: 'password',
        autocomplete: 'current-password', visible: () => true,
      },
      {fieldName: 'password', fieldLabel: 'New Password', inputType: 'password',
        autocomplete: 'new-password', visible: () => true,
      },
      {fieldName: 'password-again', fieldLabel: 'New Password (again)',
        inputType: 'password', autocomplete: 'new-password',
        // No need to make users double-enter their password if
        // they'll necessarily have an email set, since they can use
        // the "forgot password" flow.
        visible: () => isInPasswordSignupFields(
          ["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"]
        ),
      },
    ];
  }
});