meteor/meteor

View on GitHub
packages/accounts-base/accounts_client.js

Summary

Maintainability
F
4 days
Test Coverage
import {AccountsCommon} from "./accounts_common.js";

/**
 * @summary Constructor for the `Accounts` object on the client.
 * @locus Client
 * @class AccountsClient
 * @extends AccountsCommon
 * @instancename accountsClient
 * @param {Object} options an object with fields:
 * @param {Object} options.connection Optional DDP connection to reuse.
 * @param {String} options.ddpUrl Optional URL for creating a new DDP connection.
 * @param {'session' | 'local'} options.clientStorage Optional Define what kind of storage you want for credentials on the client. Default is 'local' to use `localStorage`. Set to 'session' to use session storage.
 */
export class AccountsClient extends AccountsCommon {
  constructor(options) {
    super(options);

    this._loggingIn = new ReactiveVar(false);
    this._loggingOut = new ReactiveVar(false);

    this._loginServicesHandle =
      this.connection.subscribe("meteor.loginServiceConfiguration");

    this._pageLoadLoginCallbacks = [];
    this._pageLoadLoginAttemptInfo = null;

    this.savedHash = window.location.hash;
    this._initUrlMatching();

    this.initStorageLocation();

    // Defined in localstorage_token.js.
    this._initLocalStorage();

    // This is for .registerClientLoginFunction & .callLoginFunction.
    this._loginFuncs = {};

    // This tracks whether callbacks registered with
    // Accounts.onLogin have been called
    this._loginCallbacksCalled = false;
  }

  initStorageLocation(options) {
    // Determine whether to use local or session storage to storage credentials and anything else.
    this.storageLocation = (options?.clientStorage === 'session' || Meteor.settings?.public?.packages?.accounts?.clientStorage === 'session') ? window.sessionStorage : Meteor._localStorage;
  }

  config(options) {
    super.config(options);

    this.initStorageLocation(options);
  }

  ///
  /// CURRENT USER
  ///

  // @override
  userId() {
    return this.connection.userId();
  }

  // This is mostly just called within this file, but Meteor.loginWithPassword
  // also uses it to make loggingIn() be true during the beginPasswordExchange
  // method call too.
  _setLoggingIn(x) {
    this._loggingIn.set(x);
  }

  /**
   * @summary True if a login method (such as `Meteor.loginWithPassword`, `Meteor.loginWithFacebook`, or `Accounts.createUser`) is currently in progress. A reactive data source.
   * @locus Client
   */
  loggingIn() {
    return this._loggingIn.get();
  }

  /**
   * @summary True if a logout method (such as `Meteor.logout`) is currently in progress. A reactive data source.
   * @locus Client
   */
  loggingOut() {
    return this._loggingOut.get();
  }

  /**
   * @summary Register a new login function on the client. Intended for OAuth package authors. You can call the login function by using
   `Accounts.callLoginFunction` or `Accounts.callLoginFunction`.
   * @locus Client
   * @param {String} funcName The name of your login function. Used by `Accounts.callLoginFunction` and `Accounts.applyLoginFunction`.
   Should be the OAuth provider name accordingly.
   * @param {Function} func The actual function you want to call. Just write it in the manner of `loginWithFoo`.
   */
  registerClientLoginFunction(funcName, func) {
    if (this._loginFuncs[funcName]) {
      throw new Error(`${funcName} has been defined already`);
    }
    this._loginFuncs[funcName] = func;
  }

  /**
   * @summary Call a login function defined using `Accounts.registerClientLoginFunction`. Excluding the first argument, all remaining
   arguments are passed to the login function accordingly. Use `applyLoginFunction` if you want to pass in an arguments array that contains
   all arguments for the login function.
   * @locus Client
   * @param {String} funcName The name of the login function you wanted to call.
   */
  callLoginFunction(funcName, ...funcArgs) {
    if (!this._loginFuncs[funcName]) {
      throw new Error(`${funcName} was not defined`);
    }
    return this._loginFuncs[funcName].apply(this, funcArgs);
  }

  /**
   * @summary Same as ``callLoginFunction` but accept an `arguments` which contains all arguments for the login
   function.
   * @locus Client
   * @param {String} funcName The name of the login function you wanted to call.
   * @param {Array} funcArgs The `arguments` for the login function.
   */
  applyLoginFunction(funcName, funcArgs) {
    if (!this._loginFuncs[funcName]) {
      throw new Error(`${funcName} was not defined`);
    }
    return this._loginFuncs[funcName].apply(this, funcArgs);
  }

  /**
   * @summary Log the user out.
   * @locus Client
   * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure.
   */
  logout(callback) {
    this._loggingOut.set(true);
    this.connection.apply('logout', [], {
      wait: true
    }, (error, result) => {
      this._loggingOut.set(false);
      this._loginCallbacksCalled = false;
      if (error) {
        callback && callback(error);
      } else {
        this.makeClientLoggedOut();
        callback && callback();
      }
    });
  }

  /**
   * @summary Log out other clients logged in as the current user, but does not log out the client that calls this function.
   * @locus Client
   * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure.
   */
  logoutOtherClients(callback) {
    // We need to make two method calls: one to replace our current token,
    // and another to remove all tokens except the current one. We want to
    // call these two methods one after the other, without any other
    // methods running between them. For example, we don't want `logout`
    // to be called in between our two method calls (otherwise the second
    // method call would return an error). Another example: we don't want
    // logout to be called before the callback for `getNewToken`;
    // otherwise we would momentarily log the user out and then write a
    // new token to localStorage.
    //
    // To accomplish this, we make both calls as wait methods, and queue
    // them one after the other, without spinning off the event loop in
    // between. Even though we queue `removeOtherTokens` before
    // `getNewToken`, we won't actually send the `removeOtherTokens` call
    // until the `getNewToken` callback has finished running, because they
    // are both wait methods.
    this.connection.apply(
      'getNewToken',
      [],
      { wait: true },
      (err, result) => {
        if (! err) {
          this._storeLoginToken(
            this.userId(),
            result.token,
            result.tokenExpires
          );
        }
      }
    );

    this.connection.apply(
      'removeOtherTokens',
      [],
      { wait: true },
      err => callback && callback(err)
    );
  }

  ///
  /// LOGIN METHODS
  ///

  // Call a login method on the server.
  //
  // A login method is a method which on success calls `this.setUserId(id)` and
  // `Accounts._setLoginToken` on the server and returns an object with fields
  // 'id' (containing the user id), 'token' (containing a resume token), and
  // optionally `tokenExpires`.
  //
  // This function takes care of:
  //   - Updating the Meteor.loggingIn() reactive data source
  //   - Calling the method in 'wait' mode
  //   - On success, saving the resume token to localStorage
  //   - On success, calling Accounts.connection.setUserId()
  //   - Setting up an onReconnect handler which logs in with
  //     the resume token
  //
  // Options:
  // - methodName: The method to call (default 'login')
  // - methodArguments: The arguments for the method
  // - validateResult: If provided, will be called with the result of the
  //                 method. If it throws, the client will not be logged in (and
  //                 its error will be passed to the callback).
  // - userCallback: Will be called with no arguments once the user is fully
  //                 logged in, or with the error on error.
  //
  callLoginMethod(options) {
    options = {
      methodName: 'login',
      methodArguments: [{}],
      _suppressLoggingIn: false,
      ...options,
    };

    // Set defaults for callback arguments to no-op functions; make sure we
    // override falsey values too.
    ['validateResult', 'userCallback'].forEach(f => {
      if (!options[f])
        options[f] = () => null;
    });

    let called;
    // Prepare callbacks: user provided and onLogin/onLoginFailure hooks.
    const loginCallbacks = ({ error, loginDetails }) => {
      if (!called) {
        called = true;
        if (!error) {
          this._onLoginHook.forEach(callback => {
            callback(loginDetails);
            return true;
          });
          this._loginCallbacksCalled = true;
        } else {
          this._loginCallbacksCalled = false;
          this._onLoginFailureHook.forEach(callback => {
            callback({ error });
            return true;
          });
        }
        options.userCallback(error, loginDetails);
      }
    };

    let reconnected = false;

    // We want to set up onReconnect as soon as we get a result token back from
    // the server, without having to wait for subscriptions to rerun. This is
    // because if we disconnect and reconnect between getting the result and
    // getting the results of subscription rerun, we WILL NOT re-send this
    // method (because we never re-send methods whose results we've received)
    // but we WILL call loggedInAndDataReadyCallback at "reconnect quiesce"
    // time. This will lead to makeClientLoggedIn(result.id) even though we
    // haven't actually sent a login method!
    //
    // But by making sure that we send this "resume" login in that case (and
    // calling makeClientLoggedOut if it fails), we'll end up with an accurate
    // client-side userId. (It's important that livedata_connection guarantees
    // that the "reconnect quiesce"-time call to loggedInAndDataReadyCallback
    // will occur before the callback from the resume login call.)
    const onResultReceived = (err, result) => {
      if (err || !result || !result.token) {
        // Leave onReconnect alone if there was an error, so that if the user was
        // already logged in they will still get logged in on reconnect.
        // See issue #4970.
      } else {
        // First clear out any previously set Acccounts login onReconnect
        // callback (to make sure we don't keep piling up duplicate callbacks,
        // which would then all be triggered when reconnecting).
        if (this._reconnectStopper) {
          this._reconnectStopper.stop();
        }

        this._reconnectStopper = DDP.onReconnect(conn => {
          if (conn != this.connection) {
            return;
          }
          reconnected = true;
          // If our token was updated in storage, use the latest one.
          const storedToken = this._storedLoginToken();
          if (storedToken) {
            result = {
              token: storedToken,
              tokenExpires: this._storedLoginTokenExpires()
            };
          }
          if (!result.tokenExpires)
            result.tokenExpires = this._tokenExpiration(new Date());
          if (this._tokenExpiresSoon(result.tokenExpires)) {
            this.makeClientLoggedOut();
          } else {
            this.callLoginMethod({
              methodArguments: [{resume: result.token}],
              // Reconnect quiescence ensures that the user doesn't see an
              // intermediate state before the login method finishes. So we don't
              // need to show a logging-in animation.
              _suppressLoggingIn: true,
              userCallback: (error, loginDetails) => {
                const storedTokenNow = this._storedLoginToken();
                if (error) {
                  // If we had a login error AND the current stored token is the
                  // one that we tried to log in with, then declare ourselves
                  // logged out. If there's a token in storage but it's not the
                  // token that we tried to log in with, we don't know anything
                  // about whether that token is valid or not, so do nothing. The
                  // periodic localStorage poll will decide if we are logged in or
                  // out with this token, if it hasn't already. Of course, even
                  // with this check, another tab could insert a new valid token
                  // immediately before we clear localStorage here, which would
                  // lead to both tabs being logged out, but by checking the token
                  // in storage right now we hope to make that unlikely to happen.
                  //
                  // If there is no token in storage right now, we don't have to
                  // do anything; whatever code removed the token from storage was
                  // responsible for calling `makeClientLoggedOut()`, or the
                  // periodic localStorage poll will call `makeClientLoggedOut`
                  // eventually if another tab wiped the token from storage.
                  if (storedTokenNow && storedTokenNow === result.token) {
                    this.makeClientLoggedOut();
                  }
                }
                // Possibly a weird callback to call, but better than nothing if
                // there is a reconnect between "login result received" and "data
                // ready".
                loginCallbacks({ error, loginDetails });
              }});
          }
        });
      }
    };

    // This callback is called once the local cache of the current-user
    // subscription (and all subscriptions, in fact) are guaranteed to be up to
    // date.
    const loggedInAndDataReadyCallback = (error, result) => {
      // If the login method returns its result but the connection is lost
      // before the data is in the local cache, it'll set an onReconnect (see
      // above). The onReconnect will try to log in using the token, and *it*
      // will call userCallback via its own version of this
      // loggedInAndDataReadyCallback. So we don't have to do anything here.
      if (reconnected)
        return;

      // Note that we need to call this even if _suppressLoggingIn is true,
      // because it could be matching a _setLoggingIn(true) from a
      // half-completed pre-reconnect login method.
      this._setLoggingIn(false);
      if (error || !result) {
        error = error || new Error(
          `No result from call to ${options.methodName}`
        );
        loginCallbacks({ error });
        return;
      }
      try {
        options.validateResult(result);
      } catch (e) {
        loginCallbacks({ error: e });
        return;
      }

      // Make the client logged in. (The user data should already be loaded!)
      this.makeClientLoggedIn(result.id, result.token, result.tokenExpires);
      loginCallbacks({ loginDetails: result });
    };

    if (!options._suppressLoggingIn) {
      this._setLoggingIn(true);
    }
    this.connection.apply(
      options.methodName,
      options.methodArguments,
      { wait: true, onResultReceived: onResultReceived },
      loggedInAndDataReadyCallback);
  }

  makeClientLoggedOut() {
    // Ensure client was successfully logged in before running logout hooks.
    if (this.connection._userId) {
      this._onLogoutHook.each(callback => {
        callback();
        return true;
      });
    }
    this._unstoreLoginToken();
    this.connection.setUserId(null);
    this._reconnectStopper && this._reconnectStopper.stop();
  }

  makeClientLoggedIn(userId, token, tokenExpires) {
    this._storeLoginToken(userId, token, tokenExpires);
    this.connection.setUserId(userId);
  }

  ///
  /// LOGIN SERVICES
  ///

  // A reactive function returning whether the loginServiceConfiguration
  // subscription is ready. Used by accounts-ui to hide the login button
  // until we have all the configuration loaded
  //
  loginServicesConfigured() {
    return this._loginServicesHandle.ready();
  };

  // Some login services such as the redirect login flow or the resume
  // login handler can log the user in at page load time.  The
  // Meteor.loginWithX functions have a callback argument, but the
  // callback function instance won't be in memory any longer if the
  // page was reloaded.  The `onPageLoadLogin` function allows a
  // callback to be registered for the case where the login was
  // initiated in a previous VM, and we now have the result of the login
  // attempt in a new VM.

  // Register a callback to be called if we have information about a
  // login attempt at page load time.  Call the callback immediately if
  // we already have the page load login attempt info, otherwise stash
  // the callback to be called if and when we do get the attempt info.
  //
  onPageLoadLogin(f) {
    if (this._pageLoadLoginAttemptInfo) {
      f(this._pageLoadLoginAttemptInfo);
    } else {
      this._pageLoadLoginCallbacks.push(f);
    }
  };

  // Receive the information about the login attempt at page load time.
  // Call registered callbacks, and also record the info in case
  // someone's callback hasn't been registered yet.
  //
  _pageLoadLogin(attemptInfo) {
    if (this._pageLoadLoginAttemptInfo) {
      Meteor._debug(
        'Ignoring unexpected duplicate page load login attempt info'
      );
      return;
    }

    this._pageLoadLoginCallbacks.forEach(callback => callback(attemptInfo));
    this._pageLoadLoginCallbacks = [];
    this._pageLoadLoginAttemptInfo = attemptInfo;
  }

  // _startupCallback executes on onLogin callbacks
  // at registration time if already logged in
  // this can happen when new AccountsClient is created
  // before callbacks are registered see #10157
  _startupCallback(callback) {
    // Are we already logged in?
    if (this._loginCallbacksCalled) {
      // If already logged in before handler is registered, it's safe to
      // assume type is a 'resume', so we execute the callback at the end
      // of the queue so that Meteor.startup can complete before any
      // embedded onLogin callbacks would execute.
      Meteor.setTimeout(() => callback({ type: 'resume' }), 0);
    }
  }

  ///
  /// LOGIN TOKENS
  ///

  // These methods deal with storing a login token and user id in the
  // browser's localStorage facility. It polls local storage every few
  // seconds to synchronize login state between multiple tabs in the same
  // browser.

  loginWithToken(token, callback) {
    this.callLoginMethod({
      methodArguments: [{
        resume: token
      }],
      userCallback: callback
    });
  };

  // Semi-internal API. Call this function to re-enable auto login after
  // if it was disabled at startup.
  _enableAutoLogin() {
    this._autoLoginEnabled = true;
    this._pollStoredLoginToken();
  };

  ///
  /// STORING
  ///

  // Call this from the top level of the test file for any test that does
  // logging in and out, to protect multiple tabs running the same tests
  // simultaneously from interfering with each others' localStorage.
  _isolateLoginTokenForTest() {
    this.LOGIN_TOKEN_KEY = this.LOGIN_TOKEN_KEY + Random.id();
    this.USER_ID_KEY = this.USER_ID_KEY + Random.id();
  };

  _storeLoginToken(userId, token, tokenExpires) {
    this.storageLocation.setItem(this.USER_ID_KEY, userId);
    this.storageLocation.setItem(this.LOGIN_TOKEN_KEY, token);
    if (! tokenExpires)
      tokenExpires = this._tokenExpiration(new Date());
    this.storageLocation.setItem(this.LOGIN_TOKEN_EXPIRES_KEY, tokenExpires);

    // to ensure that the localstorage poller doesn't end up trying to
    // connect a second time
    this._lastLoginTokenWhenPolled = token;
  };

  _unstoreLoginToken() {
    this.storageLocation.removeItem(this.USER_ID_KEY);
    this.storageLocation.removeItem(this.LOGIN_TOKEN_KEY);
    this.storageLocation.removeItem(this.LOGIN_TOKEN_EXPIRES_KEY);

    // to ensure that the localstorage poller doesn't end up trying to
    // connect a second time
    this._lastLoginTokenWhenPolled = null;
  };

  // This is private, but it is exported for now because it is used by a
  // test in accounts-password.
  _storedLoginToken() {
    return this.storageLocation.getItem(this.LOGIN_TOKEN_KEY);
  };

  _storedLoginTokenExpires() {
    return this.storageLocation.getItem(this.LOGIN_TOKEN_EXPIRES_KEY);
  };

  _storedUserId() {
    return this.storageLocation.getItem(this.USER_ID_KEY);
  };

  _unstoreLoginTokenIfExpiresSoon() {
    const tokenExpires = this._storedLoginTokenExpires();
    if (tokenExpires && this._tokenExpiresSoon(new Date(tokenExpires))) {
      this._unstoreLoginToken();
    }
  };

  ///
  /// AUTO-LOGIN
  ///

  _initLocalStorage() {
    // Key names to use in localStorage
    this.LOGIN_TOKEN_KEY = "Meteor.loginToken";
    this.LOGIN_TOKEN_EXPIRES_KEY = "Meteor.loginTokenExpires";
    this.USER_ID_KEY = "Meteor.userId";

    const rootUrlPathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX;
    if (rootUrlPathPrefix || this.connection !== Meteor.connection) {
      // We want to keep using the same keys for existing apps that do not
      // set a custom ROOT_URL_PATH_PREFIX, so that most users will not have
      // to log in again after an app updates to a version of Meteor that
      // contains this code, but it's generally preferable to namespace the
      // keys so that connections from distinct apps to distinct DDP URLs
      // will be distinct in Meteor._localStorage.
      let namespace = `:${this.connection._stream.rawUrl}`;
      if (rootUrlPathPrefix) {
        namespace += `:${rootUrlPathPrefix}`;
      }
      this.LOGIN_TOKEN_KEY += namespace;
      this.LOGIN_TOKEN_EXPIRES_KEY += namespace;
      this.USER_ID_KEY += namespace;
    }

    let token;
    if (this._autoLoginEnabled) {
      // Immediately try to log in via local storage, so that any DDP
      // messages are sent after we have established our user account
      this._unstoreLoginTokenIfExpiresSoon();
      token = this._storedLoginToken();
      if (token) {
        // On startup, optimistically present us as logged in while the
        // request is in flight. This reduces page flicker on startup.
        const userId = this._storedUserId();
        userId && this.connection.setUserId(userId);
        this.loginWithToken(token, err => {
          if (err) {
            Meteor._debug(`Error logging in with token: ${err}`);
            this.makeClientLoggedOut();
          }

          this._pageLoadLogin({
            type: "resume",
            allowed: !err,
            error: err,
            methodName: "login",
            // XXX This is duplicate code with loginWithToken, but
            // loginWithToken can also be called at other times besides
            // page load.
            methodArguments: [{resume: token}]
          });
        });
      }
    }

    // Poll local storage every 3 seconds to login if someone logged in in
    // another tab
    this._lastLoginTokenWhenPolled = token;

    if (this._pollIntervalTimer) {
      // Unlikely that _initLocalStorage will be called more than once for
      // the same AccountsClient instance, but just in case...
      clearInterval(this._pollIntervalTimer);
    }

    this._pollIntervalTimer = setInterval(() => {
      this._pollStoredLoginToken();
    }, 3000);
  };

  _pollStoredLoginToken() {
    if (! this._autoLoginEnabled) {
      return;
    }

    const currentLoginToken = this._storedLoginToken();

    // != instead of !== just to make sure undefined and null are treated the same
    if (this._lastLoginTokenWhenPolled != currentLoginToken) {
      if (currentLoginToken) {
        this.loginWithToken(currentLoginToken, (err) => {
          if (err) {
            this.makeClientLoggedOut();
          }
        });
      } else {
        this.logout();
      }
    }

    this._lastLoginTokenWhenPolled = currentLoginToken;
  };

  ///
  /// URLS
  ///

  _initUrlMatching() {
    // By default, allow the autologin process to happen.
    this._autoLoginEnabled = true;

    // We only support one callback per URL.
    this._accountsCallbacks = {};

    // Try to match the saved value of window.location.hash.
    this._attemptToMatchHash();
  };

  // Separate out this functionality for testing
  _attemptToMatchHash() {
    attemptToMatchHash(this, this.savedHash, defaultSuccessHandler);
  };

  /**
   * @summary Register a function to call when a reset password link is clicked
   * in an email sent by
   * [`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail).
   * This function should be called in top-level code, not inside
   * `Meteor.startup()`.
   * @memberof! Accounts
   * @name onResetPasswordLink
   * @param  {Function} callback The function to call. It is given two arguments:
   *
   * 1. `token`: A password reset token that can be passed to
   * [`Accounts.resetPassword`](#accounts_resetpassword).
   * 2. `done`: A function to call when the password reset UI flow is complete. The normal
   * login process is suspended until this function is called, so that the
   * password for user A can be reset even if user B was logged in.
   * @locus Client
   */
  onResetPasswordLink(callback) {
    if (this._accountsCallbacks["reset-password"]) {
      Meteor._debug("Accounts.onResetPasswordLink was called more than once. " +
        "Only one callback added will be executed.");
    }

    this._accountsCallbacks["reset-password"] = callback;
  };

  /**
   * @summary Register a function to call when an email verification link is
   * clicked in an email sent by
   * [`Accounts.sendVerificationEmail`](#accounts_sendverificationemail).
   * This function should be called in top-level code, not inside
   * `Meteor.startup()`.
   * @memberof! Accounts
   * @name onEmailVerificationLink
   * @param  {Function} callback The function to call. It is given two arguments:
   *
   * 1. `token`: An email verification token that can be passed to
   * [`Accounts.verifyEmail`](#accounts_verifyemail).
   * 2. `done`: A function to call when the email verification UI flow is complete.
   * The normal login process is suspended until this function is called, so
   * that the user can be notified that they are verifying their email before
   * being logged in.
   * @locus Client
   */
  onEmailVerificationLink(callback) {
    if (this._accountsCallbacks["verify-email"]) {
      Meteor._debug("Accounts.onEmailVerificationLink was called more than once. " +
        "Only one callback added will be executed.");
    }

    this._accountsCallbacks["verify-email"] = callback;
  };

  /**
   * @summary Register a function to call when an account enrollment link is
   * clicked in an email sent by
   * [`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail).
   * This function should be called in top-level code, not inside
   * `Meteor.startup()`.
   * @memberof! Accounts
   * @name onEnrollmentLink
   * @param  {Function} callback The function to call. It is given two arguments:
   *
   * 1. `token`: A password reset token that can be passed to
   * [`Accounts.resetPassword`](#accounts_resetpassword) to give the newly
   * enrolled account a password.
   * 2. `done`: A function to call when the enrollment UI flow is complete.
   * The normal login process is suspended until this function is called, so that
   * user A can be enrolled even if user B was logged in.
   * @locus Client
   */
  onEnrollmentLink(callback) {
    if (this._accountsCallbacks["enroll-account"]) {
      Meteor._debug("Accounts.onEnrollmentLink was called more than once. " +
        "Only one callback added will be executed.");
    }

    this._accountsCallbacks["enroll-account"] = callback;
  };

}

/**
 * @summary True if a login method (such as `Meteor.loginWithPassword`,
 * `Meteor.loginWithFacebook`, or `Accounts.createUser`) is currently in
 * progress. A reactive data source.
 * @locus Client
 * @importFromPackage meteor
 */
Meteor.loggingIn = () => Accounts.loggingIn();

/**
 * @summary True if a logout method (such as `Meteor.logout`) is currently in
 * progress. A reactive data source.
 * @locus Client
 * @importFromPackage meteor
 */
Meteor.loggingOut = () => Accounts.loggingOut();

/**
 * @summary Log the user out.
 * @locus Client
 * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure.
 * @importFromPackage meteor
 */
Meteor.logout = callback => Accounts.logout(callback);

/**
 * @summary Log out other clients logged in as the current user, but does not log out the client that calls this function.
 * @locus Client
 * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure.
 * @importFromPackage meteor
 */
Meteor.logoutOtherClients = callback => Accounts.logoutOtherClients(callback);

/**
 * @summary Login with a Meteor access token.
 * @locus Client
 * @param {Object} [token] Local storage token for use with login across
 * multiple tabs in the same browser.
 * @param {Function} [callback] Optional callback. Called with no arguments on
 * success.
 * @importFromPackage meteor
 */
Meteor.loginWithToken = (token, callback) =>
  Accounts.loginWithToken(token, callback);

///
/// HANDLEBARS HELPERS
///

// If our app has a Blaze, register the {{currentUser}} and {{loggingIn}}
// global helpers.
if (Package.blaze) {
  const { Template } = Package.blaze.Blaze;

  /**
   * @global
   * @name  currentUser
   * @isHelper true
   * @summary Calls [Meteor.user()](#meteor_user). Use `{{#if currentUser}}` to check whether the user is logged in.
   */
  Template.registerHelper('currentUser', () => Meteor.user());

  // TODO: the code above needs to be changed to Meteor.userAsync() when we have
  // a way to make it reactive using async.
  // Template.registerHelper('currentUserAsync',
  //  async () => await Meteor.userAsync());

  /**
   * @global
   * @name  loggingIn
   * @isHelper true
   * @summary Calls [Meteor.loggingIn()](#meteor_loggingin).
   */
  Template.registerHelper('loggingIn', () => Meteor.loggingIn());

  /**
   * @global
   * @name  loggingOut
   * @isHelper true
   * @summary Calls [Meteor.loggingOut()](#meteor_loggingout).
   */
  Template.registerHelper('loggingOut', () => Meteor.loggingOut());

  /**
   * @global
   * @name  loggingInOrOut
   * @isHelper true
   * @summary Calls [Meteor.loggingIn()](#meteor_loggingin) or [Meteor.loggingOut()](#meteor_loggingout).
   */
  Template.registerHelper(
    'loggingInOrOut',
    () => Meteor.loggingIn() || Meteor.loggingOut()
  );
}

const defaultSuccessHandler = function(token, urlPart) {
  // put login in a suspended state to wait for the interaction to finish
  this._autoLoginEnabled = false;

  // wait for other packages to register callbacks
  Meteor.startup(() => {
    // if a callback has been registered for this kind of token, call it
    if (this._accountsCallbacks[urlPart]) {
      this._accountsCallbacks[urlPart](token, () => this._enableAutoLogin());
    }
  });
}

// Note that both arguments are optional and are currently only passed by
// accounts_url_tests.js.
const attemptToMatchHash = (accounts, hash, success) => {
  // All of the special hash URLs we support for accounts interactions
  ["reset-password", "verify-email", "enroll-account"].forEach(urlPart => {
    let token;

    const tokenRegex = new RegExp(`^\\#\\/${urlPart}\\/(.*)$`);
    const match = hash.match(tokenRegex);

    if (match) {
      token = match[1];

      // XXX COMPAT WITH 0.9.3
      if (urlPart === "reset-password") {
        accounts._resetPasswordToken = token;
      } else if (urlPart === "verify-email") {
        accounts._verifyEmailToken = token;
      } else if (urlPart === "enroll-account") {
        accounts._enrollAccountToken = token;
      }
    } else {
      return;
    }

    // If no handlers match the hash, then maybe it's meant to be consumed
    // by some entirely different code, so we only clear it the first time
    // a handler successfully matches. Note that later handlers reuse the
    // savedHash, so clearing window.location.hash here will not interfere
    // with their needs.
    window.location.hash = "";

    // Do some stuff with the token we matched
    success.call(accounts, token, urlPart);
  });
}

// Export for testing
export const AccountsTest = {
  attemptToMatchHash: (hash, success) =>
    attemptToMatchHash(Accounts, hash, success),
};