define((require, exports, module) => {
  'use strict';

  const { assert } = require('chai');
  const BaseView = require('views/base');
  const Cocktail = require('cocktail');
  const PulseGraphicMixin = require('views/mixins/pulse-graphic-mixin');

  const View = BaseView.extend({
    template: () => '<div class="graphic"></div>'
  });

  Cocktail.mixin(
    View,
    PulseGraphicMixin
  );

  describe('views/mixins/pulse-graphic-mixin', () => {
    let view;

    beforeEach(() => {
      view = new View({});
      return view.render();
    });

    afterEach(() => {
      view.remove();
      view.destroy();
    });

    it('adds the `pulse` class to `.graphic`', () => {
      assert.isTrue(view.$('.graphic').hasClass('pulse'));
    });
  });
});
Esempio n. 2
0
define(function(require) {

    'use strict';

    var _ = require('underscore');
    var Marionette = require('marionette');
    var template = require('text!./button.html');
    var Cocktail = require('cocktail');
    var Backbone = require('backbone');

    require('backbone.stickit');

    var mixins = [
        require('./behaviors/create-view-model')
    ];

    var ButtonWidget = Marionette.ItemView.extend({

        template: _.template(template),

        attributes: {
            'data-widget-type': 'button',
            'class': 'form-group'
        },

        storedOptions: [
            'entity',
            'model',
            'bindingBasePath'
        ],

        events: {
            'click button': 'onClick'
        },

        bindings: {
            '> .widget-caption': 'caption'
        },

        initialize: function (options) {
            _.extend(this, _.pick(options, this.storedOptions));
        },

        onClick: function () {
            if (this.model.get('url')) {
                Backbone.history.navigate(this.model.get('url'), true);
            }
        }
    });

    Cocktail.mixin(ButtonWidget, mixins);

    return ButtonWidget;

});
define(function (require, exports, module) {
  'use strict';

  var BaseView = require('views/base');
  var chai = require('chai');
  var Cocktail = require('cocktail');
  var Relier = require('models/reliers/relier');
  var ResumeToken = require('models/resume-token');
  var ResumeTokenMixin = require('views/mixins/resume-token-mixin');
  var TestTemplate = require('stache!templates/test_template');

  var assert = chai.assert;

  var TestView = BaseView.extend({
    template: TestTemplate
  });

  Cocktail.mixin(
    TestView,
    ResumeTokenMixin
  );

  describe('views/mixins/resume-token-mixin', function () {
    var view;
    var relier;

    beforeEach(function () {
      relier = new Relier();

      view = new TestView({
        relier: relier
      });

      return view.render();
    });

    afterEach(function () {
      $('#container').empty();
    });

    describe('getResumeToken', function () {
      it('returns a ResumeToken model', function () {
        assert.instanceOf(view.getResumeToken(), ResumeToken);
      });
    });

    describe('getStringifiedResumeToken', function () {
      it('returns a stringified resume token', function () {
        assert.typeOf(view.getStringifiedResumeToken(), 'string');
      });
    });
  });
});
Esempio n. 4
0
define(function (require, exports, module) {
  'use strict';

  const SmsMixin = require('views/mixins/sms-mixin');
  const { assert } = require('chai');
  const BaseView = require('views/base');
  const Cocktail = require('cocktail');
  const sinon = require('sinon');
  const Template = require('stache!templates/test_template');

  const SmsView = BaseView.extend({
    template: Template
  });
  Cocktail.mixin(
    SmsView,
    SmsMixin
  );

  describe('views/mixins/sms-mixin', () => {
    let view;

    beforeEach(() => {

      view = new SmsView({
        windowMock: window
      });
    });

    describe('getSmsFeatures', () => {
      describe('user in `signinCodes` experiment group', () => {
        it('returns an array with `signinCodes`', () => {
          sinon.stub(view, 'isInExperimentGroup', () => true);
          assert.isTrue(view.getSmsFeatures().indexOf('signinCodes') > -1);
        });
      });

      describe('user not in `signinCodes` experiment group', () => {
        it('returns an empty array', () => {
          sinon.stub(view, 'isInExperimentGroup', () => false);
          assert.deepEqual(view.getSmsFeatures(), []);
        });
      });
    });
  });
});
Esempio n. 5
0
  describe('successMessage: false View', () => {
    const NoSuccessMessageView = View.extend({});
    Cocktail.mixin(
      NoSuccessMessageView,
      ResendMixin({ successMessage: false })
    );

    it('does not display a success message', () => {
      view = new NoSuccessMessageView();
      sinon.spy(view, 'displaySuccess');

      return view.render()
        .then(view._resend())
        .then(() => {
          assert.isFalse(view.displaySuccess.called);
        });
    });
  });
    beforeEach(function () {
      functionHandlerSpy = sinon.spy();

      var ConsumingView = BaseView.extend({
        notificationHandler: sinon.spy(),

        notifications: {
          'function-handler': functionHandlerSpy,
          'string-handler': 'notificationHandler'
        }
      });

      Cocktail.mixin(ConsumingView, NotifierMixin);

      notifier = new Notifier();
      view = new ConsumingView({
        notifier: notifier
      });
    });
  describe('lib/search-param', () => {
    let windowMock;
    let view;

    const View = Backbone.View.extend({
      initialize (options) {
        this.window = options.window;
      }
    });

    Cocktail.mixin(
      View,
      SearchParamMixin
    );

    beforeEach(() => {
      windowMock = new WindowMock();
      view = new View({ window: windowMock });
    });

    describe('getSearchParam', () => {
      it('returns the value of a search parameter, if available', () => {
        windowMock.location.search = TestHelpers.toSearchString({
          searchParam: 'value'
        });
        assert.equal(view.getSearchParam('searchParam'), 'value');
        assert.isUndefined(view.getSearchParam('notAvailable'));
      });
    });

    describe('getSearchParams', () => {
      it('returns an object with all search parameters', () => {
        const searchParams = {
          searchParam1: 'value1',
          searchParam2: 'value2'
        };
        windowMock.location.search = TestHelpers.toSearchString(searchParams);
        assert.deepEqual(view.getSearchParams(), searchParams);
      });
    });
  });
    it('invokes the defaultBehavior', () => {
      const view = {
        hasNavigated: sinon.spy(() => false)
      };
      Cocktail.mixin(view, ConnectAnotherDeviceMixin);

      sinon.stub(view, 'isEligibleForConnectAnotherDevice').callsFake(() => false);
      sinon.stub(view, 'navigateToConnectAnotherDeviceScreen').callsFake(() => {});

      return cadBehavior(view, account)
        .then((behavior) => {
          assert.strictEqual(behavior, defaultBehavior);

          assert.isTrue(view.isEligibleForConnectAnotherDevice.calledOnce);
          assert.isTrue(view.isEligibleForConnectAnotherDevice.calledWith(account));

          assert.isFalse(view.navigateToConnectAnotherDeviceScreen.called);

          assert.isTrue(view.hasNavigated.calledOnce);
        });
    });
Esempio n. 9
0
define(function (require, exports, module) {
  'use strict';

  var $ = require('jquery');
  var _ = require('underscore');
  var AuthErrors = require('lib/auth-errors');
  var Backbone = require('backbone');
  var Cocktail = require('cocktail');
  var domWriter = require('lib/dom-writer');
  var ErrorUtils = require('lib/error-utils');
  var NotifierMixin = require('views/mixins/notifier-mixin');
  var NullMetrics = require('lib/null-metrics');
  var Logger = require('lib/logger');
  var p = require('lib/promise');
  var Raven = require('raven');
  var TimerMixin = require('views/mixins/timer-mixin');

  var DEFAULT_TITLE = window.document.title;
  var STATUS_MESSAGE_ANIMATION_MS = 150;

  // A null metrics instance is created for unit tests. In the app,
  // when a view is initialized, an initialized Metrics instance
  // is passed in to the constructor.
  var nullMetrics = new NullMetrics();

  function displaySuccess(displayStrategy, msg) {
    this.hideError();
    var $success = this.$('.success');

    if (msg) {
      $success[displayStrategy](this.translator.get(msg));
    }

    // the 'data-shown' attribute value is added so the functional tests
    // can find out if the success message was successfully shown, even
    // if the element is then hidden. In the functional tests,
    // testSuccessWasShown removes the attribute so multiple checks for the
    // element can take place in the same test.
    $success
      .slideDown(STATUS_MESSAGE_ANIMATION_MS)
      .attr('data-shown', 'true');

    this.trigger('success', msg);
    this._isSuccessVisible = true;
  }

  function displayError(displayStrategy, err) {
    // Errors are disabled on page unload to supress errors
    // caused by aborted XHR requests.
    if (! this._areErrorsEnabled) {
      this.logger.error('Error ignored: %s', JSON.stringify(err));
      return;
    }

    this.hideSuccess();

    err = this._normalizeError(err);

    this.logError(err);
    var translated = this.translateError(err);

    var $error = this.$('.error');
    if (translated) {
      $error[displayStrategy](translated);
    }

    // the 'data-shown' attribute value is added so the functional tests
    // can find out if the error message was successfully shown, even
    // if the element is then hidden. In the functional tests,
    // testErrorWasShown removes the attribute so multiple checks for the
    // element can take place in the same test.
    $error
      .slideDown(STATUS_MESSAGE_ANIMATION_MS)
      .attr('data-shown', 'true');

    this.trigger('error', translated);

    this._isErrorVisible = true;

    return translated;
  }

  /**
   * Return the error module that produced the error, based on the error's
   * namespace.
   */
  function getErrorModule(err) {
    if (err && err.errorModule) {
      return err.errorModule;
    } else {
      return AuthErrors;
    }
  }


  var BaseView = Backbone.View.extend({
    /**
     * A class name that is added to the 'body' element pre-render
     * and removed on destroy.
     *
     * @property layoutClassName
     */
    layoutClassName: null,

    /**
     * The default view name
     *
     * @property viewName
     */
    viewName: '',

    constructor: function (options) {
      options = options || {};

      this.broker = options.broker;
      this.currentPage = options.currentPage;
      this.model = options.model || new Backbone.Model();
      this.fxaClient = options.fxaClient;
      this.metrics = options.metrics || nullMetrics;
      this.relier = options.relier;
      this.sentryMetrics = options.sentryMetrics || Raven;
      this.childViews = [];
      this.user = options.user;
      this.window = options.window || window;
      this.logger = new Logger(this.window);

      this.navigator = options.navigator || this.window.navigator || navigator;
      this.translator = options.translator || this.window.translator;

      /**
       * Prefer the `viewName` set on the object prototype. ChildViews
       * define their viewName on the prototype to avoid taking the
       * name of the parent view. This is a terrible hack, but workable
       * until a better solution arises. See #3029
       */
      if (! this.viewName && options.viewName) {
        this.viewName = options.viewName;
      }

      Backbone.View.call(this, options);

      // The mixin's initialize is called directly instead of the normal
      // override the `initialize` function because not all sub-classes
      // call the parent's `initialize`. w/o the call to the parent,
      // the mixin does not initialize correctly.
      NotifierMixin.initialize.call(this, options);

      // Prevent errors from being displayed by aborted XHR requests.
      this._boundDisableErrors = _.bind(this.disableErrors, this);
      $(this.window).on('beforeunload', this._boundDisableErrors);
    },

    /**
     * Render the view - Rendering is done asynchronously.
     *
     * Two functions can be overridden to perform data validation:
     * * beforeRender - called before rendering occurs. Can be used
     *   to perform data validation. Return a promise to
     *   perform an asynchronous check. Return false or a promise
     *   that resolves to false to prevent rendering.
     * * afterRender - called after the rendering occurs. Can be used
     *   to print an error message after the view is already rendered.
     */
    render: function () {
      var self = this;

      if (this.layoutClassName) {
        $('body').addClass(this.layoutClassName);
      }

      return p()
        .then(function () {
          return self._checkUserAuthorization();
        })
        .then(function (isUserAuthorized) {
          return isUserAuthorized && self.beforeRender();
        })
        .then(function (shouldRender) {
          // rendering is opt out.
          if (shouldRender === false) {
            return false;
          }

          return p().then(function () {
            self.destroyChildViews();

            // force a re-load of the context every time the
            // view is rendered or else stale data may
            // be returned.
            self._context = null;
            self.$el.html(self.template(self.getContext()));
          })
          .then(_.bind(self.afterRender, self))
          .then(function () {
            self.displayStatusMessages();
            self.trigger('rendered');

            return true;
          });
        });
    },

    /**
     * Write content to the DOM
     *
     * @param {string || element} content
     */
    writeToDOM: function (content) {
      return domWriter.write(this.window, content);
    },

    // Checks that the user's current account exists and is
    // verified. Returns either true or false.
    _checkUserAuthorization: function () {
      var self = this;

      return self.isUserAuthorized()
        .then(function (isUserAuthorized) {
          if (! isUserAuthorized) {
            // user is not authorized, make them sign in.
            self.logError(AuthErrors.toError('SESSION_EXPIRED'));
            self.navigate(self._reAuthPage(), {
              redirectTo: self.currentPage
            });
            return false;
          }

          if (self.mustVerify) {
            return self.isUserVerified()
              .then(function (isUserVerified) {
                if (! isUserVerified) {
                  // user is not verified, prompt them to verify.
                  self.navigate('confirm', {
                    account: self.getSignedInAccount()
                  });
                }

                return isUserVerified;
              });
          }

          return true;
        });
    },

    // If the user navigates to a page that requires auth and their session
    // is not currently cached, we ask them to sign in again. If the relier
    // specifies an email address, we force the user to use that account.
    _reAuthPage: function () {
      var self = this;
      if (self.relier && self.relier.get('email')) {
        return 'force_auth';
      }
      return 'signin';
    },

    displayStatusMessages: function () {
      var success = this.model.get('success');
      if (success) {
        this.displaySuccess(success);
        this.model.unset('success');
      }

      var successUnsafe = this.model.get('successUnsafe');
      if (successUnsafe) {
        this.displaySuccessUnsafe(successUnsafe);
        this.model.unset('successUnsafe');
      }

      var error = this.model.get('error');
      if (error) {
        this.displayError(error);
        this.model.unset('error');
      }
    },

    /**
     * Checks if the user is authorized to view the page. Currently
     * the only check is if the user is signed in and the page requires
     * authentication, but this could be extended to other types of
     * authorization as well.
     */
    isUserAuthorized: function () {
      var self = this;

      return p()
        .then(function () {
          if (self.mustAuth || self.mustVerify) {
            return self.getSignedInAccount().isSignedIn();
          }
          return true;
        });
    },

    isUserVerified: function () {
      var self = this;
      var account = self.getSignedInAccount();
      // If the cached account data shows it hasn't been verified,
      // check again and update the data if it has.
      if (! account.get('verified')) {
        return account.isVerified()
          .then(function (hasVerified) {
            if (hasVerified) {
              account.set('verified', hasVerified);
              self.user.setAccount(account);
            }
            return hasVerified;
          });
      }

      return p(true);
    },

    titleFromView: function (baseTitle) {
      var title = baseTitle || DEFAULT_TITLE;
      var titleText = this.$('header:first h1').text();
      var subText = this.$('header:first h2').text();

      if (titleText && subText) {
        title = titleText + ': ' + subText;
      } else if (titleText) {
        title = titleText;
      } else if (subText) {
        title = title + ': ' + subText;
      }

      return title;
    },

    getContext: function () {
      // use cached context, if available. This prevents the context()
      // function from being called multiple times per render.
      if (! this._context) {
        this._context = this.context() || {};
      }
      var ctx = this._context;

      // `t` is a mustache helper to translate strings.
      ctx.t = this.translateInTemplate.bind(this);

      return ctx;
    },

    context: function () {
      // Implement in subclasses
    },

    /**
     * Translate a string
     *
     * @param {string} text - string to translate
     * @returns {string}
     */
    translate: function (text) {
      return this.translator.get(text, this.getContext());
    },

    /**
     * Create a function that can be used by Mustache
     * to translate a string. Useful for translate a string
     * for use in the template, which iteself depends on
     * this.getContext(). This function avoids
     * infinite recursion.
     *
     * @param {string} [text] - string to translate
     * @returns {function}
     */
    translateInTemplate: function (text) {
      if (text) {
        return this.translate.bind(this, text);
      } else {
        return this.translate.bind(this);
      }
    },

    beforeRender: function () {
      // Implement in subclasses. If returns false, or if returns a promise
      // that resolves to false, then the view is not rendered.
      // Useful if the view must immediately redirect to another view.
    },

    afterRender: function () {
      // Implement in subclasses
    },

    // called after the view is visible.
    afterVisible: function () {
      // restyle side-by-side links to stack if they are too long
      // to fit on one line
      var linkContainer = this.$el.find('.links');
      if (linkContainer.length > 0) {
        // takes care of odd number widths
        var halfContainerWidth = Math.floor(linkContainer.width() / 2);
        var shouldResetLinkSize = false;

        linkContainer.children('a').each(function (i, item) {
          var linkWidth = linkContainer.find(item).width();
          // if any link is equal to or more than half its parent's width,
          // make *all* links in the same parent to be stacked
          if (linkWidth >= halfContainerWidth) {
            shouldResetLinkSize = true;
          }
        });

        if (shouldResetLinkSize === true) {
          linkContainer.addClass('centered');
        }
      }
      // make a huge assumption and say if the device does not have touch,
      // it's a desktop device and autofocus can be applied without
      // hiding part of the view. The no-touch class is added by
      // startup-styles
      if ($('html').hasClass('no-touch')) {
        var autofocusEl = this.$('[autofocus]');
        if (! autofocusEl.length) {
          return;
        }

        var self = this;
        var attemptFocus = function () {
          if (autofocusEl.is(':focus')) {
            return;
          }
          self.focus(autofocusEl);

          // only elements that are visible can be focused. When embedded in
          // about:accounts, the content is hidden when the first "focus" is
          // done. Keep trying to focus until the element is actually focused,
          // and then stop trying.
          if (! autofocusEl.is(':visible')) {
            self.setTimeout(attemptFocus, 50);
          }
        };

        attemptFocus();
      }
    },

    destroy: function (remove) {
      this.trigger('destroy');

      if (this.beforeDestroy) {
        this.beforeDestroy();
      }

      if (remove) {
        this.remove();
      } else {
        this.stopListening();
        this.$el.off();
      }

      if (this.layoutClassName) {
        $('body').removeClass(this.layoutClassName);
      }

      this.$(this.window).off('beforeunload', this._boundDisableErrors);

      this.destroyChildViews();

      this.trigger('destroyed');
    },

    trackChildView: function (view) {
      if (! _.contains(this.childViews, view)) {
        this.childViews.push(view);
        view.on('destroyed', _.bind(this.untrackChildView, this, view));
      }

      return view;
    },

    untrackChildView: function (view) {
      this.childViews = _.without(this.childViews, view);

      return view;
    },

    destroyChildViews: function () {
      _.invoke(this.childViews, 'destroy');

      this.childViews = [];
    },

    isChildViewTracked: function (view) {
      return _.indexOf(this.childViews, view) > -1;
    },

    /**
     * Display a success message
     * @method displaySuccess
     * If msg is not given, the contents of the .success element's text
     * will not be updated.
     */
    displaySuccess: _.partial(displaySuccess, 'text'),

    /**
     * Display a success message
     * @method displaySuccess
     * If msg is not given, the contents of the .success element's HTML
     * will not be updated.
     */
    displaySuccessUnsafe: _.partial(displaySuccess, 'html'),

    hideSuccess: function () {
      this.$('.success').slideUp(STATUS_MESSAGE_ANIMATION_MS);
      this._isSuccessVisible = false;
    },

    /**
     * Return true if the success message is visible
     */
    isSuccessVisible: function () {
      return !! this._isSuccessVisible;
    },

    /**
     * Display an error message.
     * @method translateError
     * @param {string} err - an error object
     *
     * @return {string} translated error text (if available), untranslated
     *   error text otw.
     */
    translateError: function (err) {
      var errors = getErrorModule(err);
      var translated = errors.toInterpolatedMessage(err, this.translator);

      return translated;
    },

    _areErrorsEnabled: true,
    /**
     * Disable logging and display of errors.
     *
     * @method disableErrors
     */
    disableErrors: function () {
      this._areErrorsEnabled = false;
    },

    /**
     * Display an error message.
     * @method displayError
     * @param {string} err - If err is not given, the contents of the
     *   `.error` element's text will not be updated.
     *
     * @return {string} translated error text (if available), untranslated
     *   error text otw.
     */
    displayError: _.partial(displayError, 'text'),

    /**
     * Display an error message that may contain HTML. Marked unsafe
     * because msg could contain XSS. Use with caution and never
     * with unsanitized user generated content.
     *
     * @method displayErrorUnsafe
     * @param {string} err - If err is not given, the contents of the
     *   `.error` element's text will not be updated.
     *
     * @return {string} translated error text (if available), untranslated
     *   error text otw.
     */
    displayErrorUnsafe: _.partial(displayError, 'html'),

    /**
     * Log an error to the event stream
     */
    logError: function (err) {
      err = this._normalizeError(err);

      // The error could already be logged, if so, abort mission.
      // This can occur when `navigate` redirects a user to a different
      // view and an error is passed. The error is logged before the view
      // transition, the new view is rendered, then the original error is
      // displayed. This avoids duplicate entries.
      if (err.logged) {
        return;
      }
      err.logged = true;

      ErrorUtils.captureError(err, this.sentryMetrics, this.metrics);
    },


    /**
     * Handle a fatal error. Logs and reports the error, then redirects
     * to the appropriate error page.
     *
     * @param {Error} error
     * @returns {promise}
     */
    fatalError: function (err) {
      return ErrorUtils.fatalError(
        err, this.sentryMetrics, this.metrics, this.window, this.translator);
    },

    /**
     * Get the view's name.
     *
     * @returns {string}
     */
    getViewName: function () {
      return this.viewName;
    },

    _normalizeError: function (err) {
      var errors = getErrorModule(err);
      if (! err) {
        // likely an error in logic, display an unexpected error to the
        // user and show a console trace to help us debug.
        err = errors.toError('UNEXPECTED_ERROR');

        this.logger.trace();
      }

      if (_.isString(err)) {
        err = new Error(err);
      }

      if (! err.context) {
        err.context = this.getViewName();
      }

      return err;
    },

    /**
     * Log the current view
     */
    logView: function () {
      this.metrics.logView(this.getViewName());
    },

    /**
     * Log an event to the event stream
     */
    logEvent: function (eventName) {
      this.metrics.logEvent(eventName);
    },

    /**
     * Log an event with the view name as a prefix
     *
     * @param {string} eventName
     */
    logViewEvent: function (eventName) {
      this.metrics.logViewEvent(this.getViewName(), eventName);
    },

    hideError: function () {
      this.$('.error').slideUp(STATUS_MESSAGE_ANIMATION_MS);
      this._isErrorVisible = false;
    },

    isErrorVisible: function () {
      return !! this._isErrorVisible;
    },

    /**
     * navigate to another screen
     *
     * @param {string} url - url of screen
     * @param {object} [nextViewData] - data to pass to the next view
     * @param {routerOptions} [routerOptions] - options to pass to the router
     */
    navigate: function (url, nextViewData, routerOptions) {
      nextViewData = nextViewData || {};
      routerOptions = routerOptions || {};

      if (nextViewData.error) {
        // log the error entry before the new view is rendered so events
        // stay in the correct order.
        this.logError(nextViewData.error);
      }

      this.notifier.trigger('navigate', {
        nextViewData: nextViewData,
        routerOptions: routerOptions,
        url: url
      });
    },

    /**
     * Safely focus an element
     */
    focus: function (which) {
      try {
        var focusEl = this.$(which);
        // place the cursor at the end of the input when the
        // element is focused.
        focusEl.one('focus', function () {
          try {
            this.selectionStart = this.selectionEnd = this.value.length;
          } catch (e) {
            // This can blow up on password fields in Chrome. Drop the error on
            // the ground, for whatever reason, it still behaves as we expect.
          }
        });
        focusEl.get(0).focus();
      } catch (e) {
        // IE can blow up if the element is not visible.
      }
    },

    /**
     * Invoke the specified handler with the given event. Handler
     * can either be a function or a string. If a string, looks for
     * the handler on `this`.
     *
     * @method invokeHandler
     * @param {string || function} handler.
     */
    invokeHandler: function (handler/*, args...*/) {
      // convert a name to a function.
      if (_.isString(handler)) {
        handler = this[handler];

        if (! _.isFunction(handler)) {
          throw new Error(handler + ' is an invalid function name');
        }
      }

      if (_.isFunction(handler)) {
        var args = [].slice.call(arguments, 1);

        // If an `arguments` type object was passed in as the first item,
        // then use that as the arguments list. Otherwise, use all arguments.
        if (_.isArguments(args[0])) {
          args = args[0];
        }

        return handler.apply(this, args);
      }
    },

    /**
     * Returns the currently logged in account
     */
    getSignedInAccount: function () {
      return this.user.getSignedInAccount();
    },

    /**
     * Returns the account that is active in the current view. It may not
     * be the currently logged in account.
     */
    getAccount: function () {
      // Implement in subclasses
    },

    /**
     * Shows the ChildView, creating and rendering it if needed.
     *
     * @param {function} ChildView - child view's constructor
     * @param {object} [options] - options to send.
     * @return {promise} resolves when complete
     */
    showChildView: function (/* ChildView, options */) {
      // Implement in subclasses
      return p();
    },

    /**
     * Invoke a method on the broker, handling any returned behaviors
     *
     * @method invokeBrokerMethod
     * @param {string} methodName
     * @param ...
     * @return {promise}
     */
    invokeBrokerMethod: function (methodName/*, ...*/) {
      var args = [].slice.call(arguments, 1);

      var self = this;
      var broker = self.broker;

      return p(broker[methodName].apply(broker, args))
        .then(self.invokeBehavior.bind(self));
    },

    /**
     * Invoke a behavior returned by an auth broker.
     *
     * @method invokeBehavior
     * @param {function} behavior
     * @return {variant} behavior's return value if behavior is a function,
     *         otherwise return the behavior.
     */
    invokeBehavior: function (behavior) {
      if (_.isFunction(behavior)) {
        return behavior(this);
      }

      return behavior;
    }
  });

  /**
   * Return a backbone compatible event handler that
   * prevents the default action, then calls the specified handler.
   * @method Baseview.preventDefaultThen
   * handler can be either a string or a function. If a string, this[handler]
   * will be called. Handler called with context of "this" view and is passed
   * the event
   */
  BaseView.preventDefaultThen = function (handler) {
    return function (event) {
      if (event) {
        event.preventDefault();
      }

      var args = [].slice.call(arguments, 0);
      args.unshift(handler);
      return this.invokeHandler.apply(this, args);
    };
  };

  /**
   * Completely cancel an event (preventDefault, stopPropagation), then call
   * the handler
   * @method BaseView.cancelEventThen
   */
  BaseView.cancelEventThen = function (handler) {
    return function (event) {
      if (event) {
        event.preventDefault();
        event.stopPropagation();
      }

      var args = [].slice.call(arguments, 0);
      args.unshift(handler);
      return this.invokeHandler.apply(this, args);
    };
  };

  /**
   * t is a wrapper that is used for string extraction. The extraction
   * script looks for t(...), and the translator will eventually
   * translate it. t is put onto BaseView instead of
   * Translator to reduce the number of dependencies in the views.
   */
  function t(str) {
    return str;
  }

  BaseView.t = t;

  Cocktail.mixin(
    BaseView,
    TimerMixin
  );

  module.exports = BaseView;
});
define(function(require, exports, module) {
  'use strict';

  const { assert } = require('chai');
  const AuthBroker = require('models/auth_brokers/base');
  const BaseView = require('views/base');
  const Cocktail = require('cocktail');
  const EmailFirstExperimentMixin = require('views/mixins/email-first-experiment-mixin');
  const Notifier = require('lib/channels/notifier');
  const Relier = require('models/reliers/relier');
  const sinon = require('sinon');

  class View extends BaseView {
    constructor (options) {
      super(options);
      this.className = 'redirects';
    }
  }

  Cocktail.mixin(
    View,
    EmailFirstExperimentMixin({ treatmentPathname: '/' })
  );

  describe('views/mixins/email-first-experiment-mixin', () => {
    let broker;
    let notifier;
    let relier;
    let sandbox;
    let view;

    before(() => {
      sandbox = sinon.sandbox.create();

      broker = new AuthBroker();
      broker.setCapability('emailFirst', true);
      notifier = new Notifier();
      relier = new Relier();

      view = new View({
        broker,
        notifier,
        relier,
        viewName: 'email'
      });
    });

    afterEach(() => {
      sandbox.restore();
      relier.unset('action');
    });

    after(() => {
      view.remove();
      view.destroy();

      view = null;
    });

    it('exposes the expected interface', () => {
      const mixin = EmailFirstExperimentMixin();
      assert.lengthOf(Object.keys(mixin), 6);
      assert.isArray(mixin.dependsOn);
      assert.isFunction(mixin.beforeRender);
      assert.isFunction(mixin.getEmailFirstExperimentGroup);
      assert.isFunction(mixin.isInEmailFirstExperiment);
      assert.isFunction(mixin.isInEmailFirstExperimentGroup);
      assert.isFunction(mixin._getEmailFirstExperimentSubject);
    });

    it('_getEmailFirstExperimentSubject returns the expected object', () => {
      assert.deepEqual(view._getEmailFirstExperimentSubject(), {
        isEmailFirstSupported: true
      });
    });

    it('isInEmailFirstExperiment delegates to `isInExperiment` correctly', () => {
      sandbox.stub(view, 'isInExperiment').callsFake(() => true);

      assert.isTrue(view.isInEmailFirstExperiment());

      assert.isTrue(view.isInExperiment.calledOnce);
      assert.isTrue(view.isInExperiment.calledWith(
        'emailFirst',
        {
          isEmailFirstSupported: true
        }
      ));
    });

    it('isInEmailFirstExperimentGroup delegates to `isInExperimentGroup` correctly', () => {
      sandbox.stub(view, 'isInExperimentGroup').callsFake(() => true);

      assert.isTrue(view.isInEmailFirstExperimentGroup('treatment'));

      assert.isTrue(view.isInExperimentGroup.calledOnce);
      assert.isTrue(view.isInExperimentGroup.calledWith(
        'emailFirst',
        'treatment',
        {
          isEmailFirstSupported: true
        }
      ));
    });

    describe('beforeRender', () => {
      beforeEach(() => {
        sandbox.spy(view, 'createExperiment');
        sandbox.spy(view, 'replaceCurrentPage');
      });

      it('redirects to the treatment page if `action=email`', () => {
        relier.set('action', 'email');

        view.beforeRender();

        assert.isTrue(view.replaceCurrentPage.calledOnceWith('/'));
      });

      it('does nothing for users not in the experiment', () => {
        sandbox.stub(view, 'isInEmailFirstExperiment').callsFake(() => false);
        sandbox.stub(view, 'isInEmailFirstExperimentGroup').callsFake(() => false);

        view.beforeRender();

        assert.isTrue(view.isInEmailFirstExperiment.calledOnce);
        assert.isFalse(view.createExperiment.called);
        assert.isFalse(view.replaceCurrentPage.called);
      });

      it('creates the experiment for users in the control group, does not redirect', () => {
        sandbox.stub(view, 'isInEmailFirstExperiment').callsFake(() => true);
        sandbox.stub(view, 'getEmailFirstExperimentGroup').callsFake(() => 'control');

        view.beforeRender();

        assert.isTrue(view.isInEmailFirstExperiment.calledOnce);

        assert.isTrue(view.createExperiment.calledOnce);
        assert.isTrue(view.createExperiment.calledWith('emailFirst', 'control'));

        assert.isFalse(view.replaceCurrentPage.called);
      });

      it('creates the experiment for users in the treatment group, redirects if treatmentPathname specified', () => {
        sandbox.stub(view, 'isInEmailFirstExperiment').callsFake(() => true);
        sandbox.stub(view, 'getEmailFirstExperimentGroup').callsFake(() => 'treatment');

        view.beforeRender();

        assert.isTrue(view.createExperiment.calledOnce);
        assert.isTrue(view.createExperiment.calledWith('emailFirst', 'treatment'));

        assert.isTrue(view.replaceCurrentPage.calledOnce);
        assert.isTrue(view.replaceCurrentPage.calledWith('/'));
      });
    });
  });
});
Esempio n. 11
0
describe('models/mixins/resume-token', function () {
  var model, sentryMetrics;
  var UTM_CAMPAIGN = 'deadbeef';
  var RESUME_SCHEMA = {
    utmCampaign: vat.hex().len(8).required()
  };
  var VALID_RESUME_DATA = {
    notResumeable: 'this should not be picked',
    utmCampaign: UTM_CAMPAIGN
  };
  var INVALID_RESUME_DATA = {
    utmCampaign: 'foo'
  };
  var MISSING_RESUME_DATA = {};

  var Model = Backbone.Model.extend({
    initialize (options) {
      this.sentryMetrics = sentryMetrics;
      this.window = options.window;
    },

    resumeTokenFields: ['utmCampaign'],

    resumeTokenSchema: RESUME_SCHEMA
  });

  Cocktail.mixin(
    Model,
    ResumeTokenMixin
  );

  beforeEach(function () {
    sentryMetrics = {
      captureException: sinon.spy()
    };
    model = new Model({});
  });

  describe('pickResumeTokenInfo', function () {
    it('returns an object with info to be passed along with email verification links', function () {
      model.set(VALID_RESUME_DATA);

      assert.deepEqual(model.pickResumeTokenInfo(), {
        utmCampaign: UTM_CAMPAIGN
      });
    });
  });

  describe('populateFromResumeToken with valid data', function () {
    beforeEach(function () {
      var resumeToken = new ResumeToken(VALID_RESUME_DATA);
      model.populateFromResumeToken(resumeToken);
    });

    it('populates the model with data from the ResumeToken', function () {
      assert.equal(model.get('utmCampaign'), UTM_CAMPAIGN);
      assert.isFalse(model.has('notResumeable'), 'only allow specific resume token values');
    });

    it('does not call sentryMetrics.captureException', function () {
      assert.strictEqual(sentryMetrics.captureException.callCount, 0);
    });
  });

  describe('populateFromResumeToken with invalid data', function () {
    beforeEach(function () {
      var resumeToken = new ResumeToken(INVALID_RESUME_DATA);
      model.populateFromResumeToken(resumeToken);
    });

    it('does not populate the model', function () {
      assert.isFalse(model.has('utmCampaign'));
    });

    it('called sentryMetrics.captureException correctly', function () {
      assert.strictEqual(sentryMetrics.captureException.callCount, 1);
      var args = sentryMetrics.captureException.args[0];
      assert.lengthOf(args, 1);
      assert.instanceOf(args[0], Error);
      assert.strictEqual(args[0].message, 'Invalid property in resume token: utmCampaign');
    });
  });

  describe('populateFromResumeToken with missing data', function () {
    beforeEach(function () {
      var resumeToken = new ResumeToken(MISSING_RESUME_DATA);
      model.populateFromResumeToken(resumeToken);
    });

    it('does not populate the model', function () {
      assert.isFalse(model.has('utmCampaign'));
    });

    it('called sentryMetrics.captureException correctly', function () {
      assert.strictEqual(sentryMetrics.captureException.callCount, 1);
      var args = sentryMetrics.captureException.args[0];
      assert.lengthOf(args, 1);
      assert.instanceOf(args[0], Error);
      assert.strictEqual(args[0].message, 'Missing property in resume token: utmCampaign');
    });
  });

  describe('populateFromStringifiedResumeToken with valid data', function () {
    beforeEach(function () {
      var stringifiedResumeToken = ResumeToken.stringify(VALID_RESUME_DATA);
      model.populateFromStringifiedResumeToken(stringifiedResumeToken);
    });

    it('parses the resume param into an object', function () {
      assert.equal(model.get('utmCampaign'), UTM_CAMPAIGN);
      assert.isFalse(model.has('notResumeable'), 'only allow specific resume token values');
    });

    it('does not call sentryMetrics.captureException', function () {
      assert.strictEqual(sentryMetrics.captureException.callCount, 0);
    });
  });

  describe('populateFromStringifiedResumeToken with invalid data', function () {
    beforeEach(function () {
      var stringifiedResumeToken = ResumeToken.stringify(INVALID_RESUME_DATA);
      model.populateFromStringifiedResumeToken(stringifiedResumeToken);
    });

    it('does not populate the model', function () {
      assert.isFalse(model.has('utmCampaign'));
    });

    it('called sentryMetrics.captureException correctly', function () {
      assert.strictEqual(sentryMetrics.captureException.callCount, 1);
      var args = sentryMetrics.captureException.args[0];
      assert.lengthOf(args, 1);
      assert.instanceOf(args[0], Error);
      assert.strictEqual(args[0].message, 'Invalid property in resume token: utmCampaign');
    });
  });

  describe('populateFromStringifiedResumeToken with missing data', function () {
    beforeEach(function () {
      var stringifiedResumeToken = ResumeToken.stringify(MISSING_RESUME_DATA);
      model.populateFromStringifiedResumeToken(stringifiedResumeToken);
    });

    it('does not populate the model', function () {
      assert.isFalse(model.has('utmCampaign'));
    });

    it('called sentryMetrics.captureException correctly', function () {
      assert.strictEqual(sentryMetrics.captureException.callCount, 1);
      var args = sentryMetrics.captureException.args[0];
      assert.lengthOf(args, 1);
      assert.instanceOf(args[0], Error);
      assert.strictEqual(args[0].message, 'Missing property in resume token: utmCampaign');
    });
  });
});
define(function (require, exports, module) {
  'use strict';

  const Cocktail = require('cocktail');
  const FloatingPlaceholderMixin = require('views/mixins/floating-placeholder-mixin');
  const FormView = require('views/form');
  const ModalSettingsPanelMixin = require('views/mixins/modal-settings-panel-mixin');
  const SignedOutNotificationMixin = require('views/mixins/signed-out-notification-mixin');
  const t = require('views/base').t;
  const Template = require('stache!templates/settings/client_disconnect');

  const REASON_HELP = {
    'lost': t('We\'re sorry to hear about this. You should change your Firefox Account password, and look for ' +
      'information from your device manufacturer about erasing your data remotely.'),
    'suspicious': t('We\'re sorry to hear about this. If this was a device you really don\'t trust, you should ' +
      'change your Firefox Account password, and change any passwords saved in Firefox.')
  };

  var View = FormView.extend({
    template: Template,
    className: 'clients-disconnect',
    viewName: 'settings.clients.disconnect',

    events: {
      'change .disconnect-reasons': 'onChangeRadioButton',
      'click': '_returnToClientListAfterDisconnect',
      'click .cancel-disconnect': FormView.preventDefaultThen('_returnToClientList'),
      'click button[type=submit]': '_returnToConnectAnotherDevice',
    },

    initialize () {
      // user is presented with an option to disconnect device
      this.hasDisconnected = false;
      this.on('modal-cancel', () => this._returnToClientList());
    },

    beforeRender () {
      // receive the device collection and the item to delete
      // if deleted the collection will be automatically updated in the settings panel.
      const clients = this.model.get('clients');
      const clientId = this.model.get('clientId');
      if (! clients || ! clientId) {
        return this._returnToClientList();
      }

      this.client = clients.get(clientId);
    },

    setInitialContext (context) {
      context.set({
        hasDisconnected: this.hasDisconnected,
        reasonHelp: this.reasonHelp
      });

      if (! this.hasDisconnected) {
        context.set('deviceName', this.client.get('name'));
      }
    },

    onChangeRadioButton() {
      this.enableSubmitIfValid();
    },

    /**
     * Called on option select.
     * If first option is selected then form is disabled using the logic in FormView.
     * If the client was disconnected then the user can press the 'Got it' button to close the modal.
     *
     * @returns {Boolean}
     */
    isValidStart () {
      if (this.hasDisconnected) {
        return true;
      }
      return (this.$('input[name=disconnect-reasons]:checked').length > 0);
    },

    submit () {
      const client = this.client;
      const selectedValue = this.$('input[name=disconnect-reasons]:checked').val();
      this.logViewEvent('submit.' + selectedValue);

      return this.user.destroyAccountClient(this.user.getSignedInAccount(), client)
        .then(() => {
          // user has disconnect the device
          this.hasDisconnected = true;
          this.reasonHelp = REASON_HELP[selectedValue];
          if (client.get('isCurrentDevice')) {
            // if disconnected the current device, the user is automatically signed out
            this.navigateToSignIn();
          } else if (this.reasonHelp) {
            // if we can provide help for this disconnect reason
            this.render();
          } else {
            // close the modal if no reason help
            this._returnToClientListAfterDisconnect();
          }
        });
    },

    /**
     * Navigates to the client list if device was disconnected.
     */
    _returnToClientListAfterDisconnect () {
      if (this.hasDisconnected) {
        this._returnToClientList();
      }
    },

    _returnToClientList () {
      this.navigate('settings/clients');
    }
  });

  Cocktail.mixin(
    View,
    ModalSettingsPanelMixin,
    FloatingPlaceholderMixin,
    SignedOutNotificationMixin
  );

  module.exports = View;
});
const ResumeTokenMixin = require('views/mixins/resume-token-mixin');
const sinon = require('sinon');
const TestTemplate = require('templates/test_template.mustache');

const TestView = BaseView.extend({
  template: TestTemplate,

  initialize (options = {}) {
    this.metrics = options.metrics;
    this.relier = options.relier;
    this.user = options.user;
  }
});

Cocktail.mixin(
  TestView,
  ResumeTokenMixin
);

describe('views/mixins/resume-token-mixin', function () {
  let account;
  let flow;
  let metrics;
  let relier;
  let user;
  let view;

  beforeEach(function () {
    account = {
      pickResumeTokenInfo: sinon.spy()
    };
define(function (require, exports, module) {
  'use strict';

  var BaseView = require('views/base');
  var Cocktail = require('cocktail');
  var Constants = require('lib/constants');
  var FormView = require('views/form');
  var MarketingEmailErrors = require('lib/marketing-email-errors');
  var Metrics = require('lib/metrics');
  var SettingsPanelMixin = require('views/mixins/settings-panel-mixin');
  var Template = require('stache!templates/settings/communication_preferences');
  var Xss = require('lib/xss');

  var NEWSLETTER_ID = Constants.MARKETING_EMAIL_NEWSLETTER_ID;
  var t = BaseView.t;

  var View = FormView.extend({
    template: Template,
    className: 'communication-preferences',
    viewName: 'settings.communication-preferences',

    enableSubmitIfValid: function () {
      // overwrite this to prevent the default FormView method from hiding errors
      // after render
      this.enableForm();
    },

    getMarketingEmailPrefs: function () {
      var self = this;
      if (! self._marketingEmailPrefs) {
        self._marketingEmailPrefs =
            self.getSignedInAccount().getMarketingEmailPrefs();
      }

      return self._marketingEmailPrefs;
    },

    // The view is rendered twice to avoid delaying the settings page load.
    // The first render is done without querying Basket for the user's email
    // opt-in status. The second is after Basket is queried. Selenium tests
    // should do their business after the second render. _isBasketReady is
    // used to add a class to the #communication-preferences element that
    // Selenium can proceed. See #3357 and #3061
    _isBasketReady: false,

    afterVisible: function () {
      var self = this;
      var emailPrefs = self.getMarketingEmailPrefs();

      // the email prefs fetch is done in afterVisible instead of a render
      // function so that the settings page render is not blocked while waiting
      // for Basket to respond.  See #3061
      return emailPrefs.fetch()
        .fail(function (err) {
          if (MarketingEmailErrors.is(err, 'UNKNOWN_EMAIL')) {
            // user has not yet opted in to Basket yet. Ignore
            return;
          }
          if (MarketingEmailErrors.is(err, 'UNKNOWN_ERROR')) {
            self._error = self.translateError(MarketingEmailErrors.toError('SERVICE_UNAVAILABLE'));
          } else {
            self._error = self.translateError(err);
          }
          err = self._normalizeError(err);
          var errorString = Metrics.prototype.errorToId(err);
          err.code = err.code || 'unknown';
          // Add status code to metrics data, to differentiate between
          // 400 and 500
          errorString = errorString + '.' + err.code;
          self.logEvent(errorString);
        })
        .then(function () {
          self._isBasketReady = true;
          return self.render();
        });
    },

    context: function () {
      var self = this;
      var emailPrefs = this.getMarketingEmailPrefs();
      var isOptedIn = emailPrefs.isOptedIn(NEWSLETTER_ID);
      self.logViewEvent('newsletter.optin.' + String(isOptedIn));

      return {
        error: self._error,
        isBasketReady: !! self._isBasketReady,
        isOptedIn: isOptedIn,
        isPanelOpen: self.isPanelOpen(),
        // preferencesURL is only available if the user is already
        // registered with basket.
        preferencesUrl: Xss.href(emailPrefs.get('preferencesUrl'))
      };
    },

    submit: function () {
      var self = this;
      var emailPrefs = self.getMarketingEmailPrefs();
      return self.setOptInStatus(NEWSLETTER_ID, ! emailPrefs.isOptedIn(NEWSLETTER_ID));
    },

    setOptInStatus: function (newsletterId, isOptedIn) {
      var self = this;

      var method = isOptedIn ? 'optIn' : 'optOut';
      self.logViewEvent(method);

      return self.getMarketingEmailPrefs()[method](newsletterId)
        .then(function () {
          self.logViewEvent(method + '.success');

          var successMessage = isOptedIn ?
                                  t('Subscribed successfully') :
                                  t('Unsubscribed successfully');

          self.displaySuccess(successMessage);
          self.navigate('settings');
          return self.render();
        }, function (err) {
          self.displayError(err);
        });
    }
  });

  Cocktail.mixin(
    View,
    SettingsPanelMixin
  );

  module.exports = View;
});
        }
      })
      .catch((err) => {
        // For invalid code param, display invalid TOTP code error
        if (AuthErrors.is(err, 'INVALID_PARAMETER')) {
          err = AuthErrors.toError('INVALID_TOTP_CODE');
        }
        return this.showValidationError(this.$(CODE_INPUT_SELECTOR), err);
      });
  },

  refresh: showProgressIndicator(function () {
    this.setLastCheckedTime();
    return this.render();
  }, CODE_REFRESH_SELECTOR, CODE_REFRESH_DELAY_MS),

});

Cocktail.mixin(
  View,
  UpgradeSessionMixin({
    gatedHref: 'settings/two_step_authentication',
    title: t('Two-step Authentication')
  }),
  AvatarMixin,
  LastCheckedTimeMixin,
  SettingsPanelMixin
);

module.exports = View;
Esempio n. 16
0
  setInitialContext(context) {
    this.recoveryKey = this._formatRecoveryKey(context.get('recoveryKey'));
    context.set({
      isIos: this.getUserAgent().isIos(),
      recoveryKey: this.recoveryKey
    });
  },

  beforeRender() {
    const account = this.getSignedInAccount();
    return account.checkRecoveryKeyExists()
      .then((status) => {
        if (! status.exists) {
          this.navigate('/settings/account_recovery');
        } else {
          this.recoveryKey = this.model.get('recoveryKey');
        }
      });
  },
});

Cocktail.mixin(
  View,
  ModalSettingsPanelMixin,
  SaveOptionsMixin,
  UserAgentMixin
);

module.exports = View;

   * Resend a signup verification link to the user. Called when a
   * user follows an expired verification link and clicks "resend"
   *
   * @returns {Promise}
   */
  resend () {
    const account = this.user.getAccountByEmail(this._email);
    return account.retrySignUp(this.relier, {
      resume: this.getStringifiedResumeToken(account)
    }).catch((err) => {
      if (AuthErrors.is(err, 'INVALID_TOKEN')) {
        return this.navigate('signup', {
          error: err
        });
      }

      // unexpected error, rethrow for display.
      throw err;
    });
  }
});

Cocktail.mixin(
  CompleteSignUpView,
  ConnectAnotherDeviceMixin,
  ResendMixin,
  ResumeTokenMixin
);

module.exports = CompleteSignUpView;
Esempio n. 18
0
  submit () {
    if (this.isPanelOpen()) {
      // only refresh devices if panel is visible
      // if panel is hidden there is no point of fetching devices.
      // The re-render is done in afterSubmit to ensure
      // the minimum artificial delay time is honored before
      // re-rendering.
      return this._fetchAttachedClients();
    }
  },

  afterSubmit () {
    // afterSubmit is called after the artificial delay has
    // expired in the progress indicator decorator. re-render
    // once the progress indicator has gone away.
    return proto.afterSubmit.call(this)
      .then(() => this.render());
  }
}, {
  MIN_REFRESH_INDICATOR_MS: 1600
});

Cocktail.mixin(
  View,
  SettingsPanelMixin,
  SignedOutNotificationMixin,
  UserAgentMixin
);

module.exports = View;
define(function (require, exports, module) {
  'use strict';

  const AuthErrors = require('../lib/auth-errors');
  const BaseView = require('./base');
  const Cocktail = require('cocktail');
  const CompleteSignUpTemplate = require('templates/complete_sign_up.mustache');
  const ConnectAnotherDeviceMixin = require('./mixins/connect-another-device-mixin');
  const MarketingEmailErrors = require('../lib/marketing-email-errors');
  const ResendMixin = require('./mixins/resend-mixin')();
  const ResumeTokenMixin = require('./mixins/resume-token-mixin');
  const VerificationInfo = require('../models/verification/sign-up');

  const CompleteSignUpView = BaseView.extend({
    template: CompleteSignUpTemplate,
    className: 'complete_sign_up',

    initialize (options = {}) {
      this._verificationInfo = new VerificationInfo(this.getSearchParams());
      const uid = this._verificationInfo.get('uid');

      this.notifier.trigger('set-uid', uid);

      const account = options.account || this.user.getAccountByUid(uid);
      // the account will not exist if verifying in a second browser, and the
      // default account will be returned. Add the uid to the account so
      // verification can still occur.
      if (account.isDefault()) {
        account.set('uid', uid);
      }

      this._account = account;

      // cache the email in case we need to attempt to resend the
      // verification link
      this._email = this._account.get('email');
    },

    getAccount () {
      return this._account;
    },

    beforeRender () {
      this.logViewEvent('verification.clicked');

      const verificationInfo = this._verificationInfo;
      if (! verificationInfo.isValid()) {
        // One or more parameters fails validation. Abort and show an
        // error message before doing any more checks.
        this.logError(AuthErrors.toError('DAMAGED_VERIFICATION_LINK'));
        return true;
      }

      const account = this.getAccount();
      // Loads the email from the resume token to smooth out the signin
      // flow if the user verifies in a 2nd Firefox.
      account.populateFromStringifiedResumeToken(this.getSearchParam('resume'));

      const code = verificationInfo.get('code');
      const options = {
        primaryEmailVerified: this.getSearchParam('primary_email_verified') || null,
        reminder: verificationInfo.get('reminder'),
        secondaryEmailVerified: this.getSearchParam('secondary_email_verified') || null,
        service: this.relier.get('service') || null,
        type: verificationInfo.get('type')
      };

      return this.user.completeAccountSignUp(account, code, options)
        .catch((err) => this._logAndAbsorbMarketingClientErrors(err))
        .then(() => this._notifyBrokerAndComplete(account))
        .catch((err) => this._handleVerificationErrors(err));
    },

    setInitialContext (context) {
      const verificationInfo = this._verificationInfo;
      context.set({
        canResend: this._canResend(),
        error: this.model.get('error'),
        // If the link is invalid, print a special error message.
        isLinkDamaged: ! verificationInfo.isValid(),
        isLinkExpired: verificationInfo.isExpired(),
        isLinkUsed: verificationInfo.isUsed(),
        isPrimaryEmailVerification: this.isPrimaryEmail()
      });
    },

    /**
     * Log and swallow any errors that are generated from attempting to
     * sign up the user to marketing email.
     *
     * @param {Error} err
     * @private
     */
    _logAndAbsorbMarketingClientErrors (err) {
      if (MarketingEmailErrors.created(err)) {
        // A basket error should not prevent the
        // sign up verification from completing, nor
        // should an error be displayed to the user.
        // Log the error and nothing else.
        this.logError(err);
      } else {
        throw err;
      }
    },

    /**
     * Notify the broker that the email is verified. Brokers are
     * expected to take care of any next steps.
     *
     * @param {Object} account
     * @returns {Promise}
     * @private
     */
    _notifyBrokerAndComplete (account) {
      this.logViewEvent('verification.success');
      this.notifier.trigger('verification.success');

      // Emitting an explicit signin event here
      // allows us to capture successes that might be
      // triggered from confirmation emails.
      if (this.isSignIn()) {
        this.logEvent('signin.success');
      }

      // Update the stored account data in case it was
      // updated by completeAccountSignUp.
      this.user.setAccount(account);

      const brokerMethod = this._getBrokerMethod();
      // The brokers handle all next steps.
      return this.invokeBrokerMethod(brokerMethod, account);
    },

    /**
     * Get the post-verification broker method name.
     *
     * @returns {String}
     * @throws Error if suitable broker method is not available.
     */
    _getBrokerMethod () {
      let brokerMethod;
      if (this.isPrimaryEmail()) {
        brokerMethod = 'afterCompletePrimaryEmail';
      } else if (this.isSecondaryEmail()) {
        brokerMethod = 'afterCompleteSecondaryEmail';
      } else if (this.isSignIn()) {
        brokerMethod = 'afterCompleteSignIn';
      } else if (this.isSignUp()) {
        brokerMethod = 'afterCompleteSignUp';
      } else {
        throw new Error(`New broker method needed for ${this.model.get('type')}`);
      }
      return brokerMethod;
    },


    /**
     * Handle any verification errors.
     *
     * @param {Error} err
     * @private
     */
    _handleVerificationErrors (err) {
      const verificationInfo = this._verificationInfo;

      if (AuthErrors.is(err, 'UNKNOWN_ACCOUNT')) {
        verificationInfo.markExpired();
        err = AuthErrors.toError('UNKNOWN_ACCOUNT_VERIFICATION');
      } else if (
        AuthErrors.is(err, 'INVALID_VERIFICATION_CODE') ||
          AuthErrors.is(err, 'INVALID_PARAMETER')) {

        if (this.isPrimaryEmail()) {
          verificationInfo.markUsed();
          err = AuthErrors.toError('REUSED_PRIMARY_EMAIL_VERIFICATION_CODE');
        } else if (this.isSignIn()) {
          // When coming from sign-in confirmation verification, show a
          // verification link expired error instead of damaged verification link.
          // This error is generated because the link has already been used.
          //
          // Disable resending verification, can only be triggered from new sign-in
          verificationInfo.markUsed();
          err = AuthErrors.toError('REUSED_SIGNIN_VERIFICATION_CODE');
        } else {
          // These server says the verification code or any parameter is
          // invalid. The entire link is damaged.
          verificationInfo.markDamaged();
          err = AuthErrors.toError('DAMAGED_VERIFICATION_LINK');
        }
      } else {
        // all other errors show the standard error box.
        this.model.set('error', err);
      }

      this.logError(err);
    },

    /**
     * Check whether the user can resend a signup verification email to allow
     * users to recover from expired verification links.
     *
     * @returns {Boolean}
     * @private
     */
    _canResend () {
      // _hasResendSessionToken only returns `true` if the user signed up in the
      // same browser in which they opened the verification link.
      return !! this._hasResendSessionToken() && this.isSignUp();
    },

    /**
     * Returns whether a sessionToken exists for the user's email.
     * The sessionToken is not cached during view initialization so that
     * we can capture sessionTokens from accounts created (in this browser)
     * since the view was loaded.
     *
     * @returns {Boolean}
     * @private
     */
    _hasResendSessionToken () {
      return !! this.user.getAccountByEmail(this._email).get('sessionToken');
    },

    /**
     * Resend a signup verification link to the user. Called when a
     * user follows an expired verification link and clicks "resend"
     *
     * @returns {Promise}
     */
    resend () {
      const account = this.user.getAccountByEmail(this._email);
      return account.retrySignUp(this.relier, {
        resume: this.getStringifiedResumeToken(account)
      }).catch((err) => {
        if (AuthErrors.is(err, 'INVALID_TOKEN')) {
          return this.navigate('signup', {
            error: err
          });
        }

        // unexpected error, rethrow for display.
        throw err;
      });
    }
  });

  Cocktail.mixin(
    CompleteSignUpView,
    ConnectAnotherDeviceMixin,
    ResendMixin,
    ResumeTokenMixin
  );

  module.exports = CompleteSignUpView;
});
define(function (require, exports, module) {
  'use strict';

  var _ = require('underscore');
  var Cocktail = require('cocktail');
  var OAuthErrors = require('lib/oauth-errors');
  var ChannelMixin = require('models/auth_brokers/mixins/channel');
  var OAuthAuthenticationBroker = require('models/auth_brokers/oauth');
  var p = require('lib/promise');
  var Vat = require('lib/vat');
  var WebChannel = require('lib/channels/web');

  var proto = OAuthAuthenticationBroker.prototype;

  var QUERY_PARAMETER_SCHEMA = {
    webChannelId: Vat.string()
  };

  var WebChannelAuthenticationBroker = OAuthAuthenticationBroker.extend({
    type: 'web-channel',
    defaults: _.extend({}, proto.defaults, {
      webChannelId: null
    }),

    initialize: function (options) {
      options = options || {};

      // channel can be passed in for testing.
      this._channel = options.channel;

      return proto.initialize.call(this, options);
    },

    fetch: function () {
      var self = this;
      return proto.fetch.call(this)
        .then(function () {
          if (self._isVerificationFlow()) {
            self._setupVerificationFlow();
          } else {
            self._setupSigninSignupFlow();
          }
        });
    },

    sendOAuthResultToRelier: function (result) {
      if (result.closeWindow !== true) {
        result.closeWindow = false;
      }

      // the WebChannel does not respond, create a promise
      // that immediately resolves.
      this.send('oauth_complete', result);
      return p();
    },

    /**
     * WebChannel reliers can request access to relier-specific encryption
     * keys.  In the future this logic may be lifted into the base OAuth class
     * and made available to all reliers, but we're putting it in this subclass
     * for now to guard against accidental exposure.
     *
     * If the relier indicates that they want keys, the OAuth result will
     * get an additional property 'keys', an object containing relier-specific
     * keys 'kAr' and 'kBr'.
     */

    getOAuthResult: function (account) {
      var self = this;
      return proto.getOAuthResult.call(this, account)
        .then(function (result) {
          if (! self.relier.wantsKeys()) {
            return result;
          }

          return account.relierKeys(self.relier)
            .then(function (relierKeys) {
              result.keys = relierKeys;
              return result;
            });
        });
    },

    afterSignIn: function (account, additionalResultData) {
      if (! additionalResultData) {
        additionalResultData = {};
      }
      additionalResultData.closeWindow = true;
      return proto.afterSignIn.call(
                this, account, additionalResultData);
    },

    afterForceAuth: function (account, additionalResultData) {
      if (! additionalResultData) {
        additionalResultData = {};
      }
      additionalResultData.closeWindow = true;
      return proto.afterForceAuth.call(
                this, account, additionalResultData);
    },

    beforeSignUpConfirmationPoll: function (account) {
      // If the relier wants keys, the signup verification tab will need
      // to be able to fetch them in order to complete the flow.
      // Send them as part of the oauth session data.
      if (this.relier.wantsKeys()) {
        this.session.set('oauth', _.extend({}, this.session.oauth, {
          keyFetchToken: account.get('keyFetchToken'),
          unwrapBKey: account.get('unwrapBKey')
        }));
      }
    },

    /**
     * If the user has to go through an email verification loop, they could
     * wind up with two tabs open that are both capable of completing the OAuth
     * flow.  To avoid sending duplicate webchannel events, and to avoid double
     * use of the keyFetchToken when the relier wants keys, we coordinate via
     * session data to ensure that only a single tab completes the flow.
     *
     * If session.oauth exists then there's an outstanding flow to be completed.
     * If it is empty then another tab must have completed the flow.
     *
     * There's still a small race window that would allow both tabs to complete,
     * but it's unlikely to trigger in practice.
     */

    hasPendingOAuthFlow: function () {
      this.session.reload();
      return !! (this.session.oauth);
    },

    afterSignUpConfirmationPoll: function (account) {
      if (this.hasPendingOAuthFlow()) {
        return this.finishOAuthSignUpFlow(account);
      }
      return p();
    },

    afterCompleteSignUp: function (account) {
      // The original tab may be closed, so the verification tab should
      // send the OAuth result to the browser to ensure the flow completes.
      //
      // The slight delay here is to allow the functional tests time to
      // bind event handlers before the flow completes.
      var self = this;
      return proto.afterCompleteSignUp.call(self, account)
        .delay(100)
        .then(function (behavior) {
          if (self.hasPendingOAuthFlow()) {
            // This tab won't have access to key-fetching material, so
            // retreive it from the session if necessary.
            if (self.relier.wantsKeys()) {
              account.set('keyFetchToken', self.session.oauth.keyFetchToken);
              account.set('unwrapBKey', self.session.oauth.unwrapBKey);
            }
            return self.finishOAuthSignUpFlow(account);
          }

          return behavior;
        });
    },

    afterResetPasswordConfirmationPoll: function (account) {
      if (this.hasPendingOAuthFlow()) {
        return this.finishOAuthSignInFlow(account);
      }
      return p();
    },

    afterCompleteResetPassword: function (account) {
      // The original tab may be closed, so the verification tab should
      // send the OAuth result to the browser to ensure the flow completes.
      //
      // The slight delay here is to allow the functional tests time to
      // bind event handlers before the flow completes.
      var self = this;
      return proto.afterCompleteResetPassword.call(self, account)
        .delay(100)
        .then(function (behavior) {
          if (self.hasPendingOAuthFlow()) {
            return self.finishOAuthSignInFlow(account);
          }

          return behavior;
        });
    },

    // used by the ChannelMixin to get a channel.
    getChannel: function () {
      if (this._channel) {
        return this._channel;
      }

      var channel = new WebChannel(this.get('webChannelId'));
      channel.initialize({
        window: this.window
      });

      return channel;
    },

    _isVerificationFlow: function () {
      return !! this.getSearchParam('code');
    },

    _setupSigninSignupFlow: function () {
      this.importSearchParamsUsingSchema(QUERY_PARAMETER_SCHEMA, OAuthErrors);
    },

    _setupVerificationFlow: function () {
      var resumeObj = this.session.oauth;

      if (! resumeObj) {
        // user is verifying in a second browser. The browser is not
        // listening for messages.
        return;
      }

      this.set('webChannelId', resumeObj.webChannelId);
    }
  });

  Cocktail.mixin(
    WebChannelAuthenticationBroker,
    ChannelMixin
  );

  module.exports = WebChannelAuthenticationBroker;
});
define(function (require, exports, module) {
  'use strict';

  const _ = require('underscore');
  const BackMixin = require('views/mixins/back-mixin');
  const CheckboxMixin = require('views/mixins/checkbox-mixin');
  const Cocktail = require('cocktail');
  const FormView = require('views/form');
  const SessionVerificationPollMixin = require('views/mixins/session-verification-poll-mixin');
  const Template = require('stache!templates/choose_what_to_sync');

  const SCREEN_CLASS = 'screen-choose-what-to-sync';

  const proto = FormView.prototype;
  const View = FormView.extend({
    template: Template,
    className: 'choose-what-to-sync',

    initialize (options = {}) {
      // Account data is passed in from sign up flow.
      this._account = this.user.initAccount(this.model.get('account'));

      // to keep the view from knowing too much about the state machine,
      // a continuation function is passed in that should be called
      // when submit has completed.
      this.onSubmitComplete = this.model.get('onSubmitComplete');
    },

    getAccount () {
      return this._account;
    },

    beforeRender () {
      // user cannot proceed if they have not initiated a sign up/in.
      if (! this.getAccount().get('sessionToken')) {
        this.navigate('signup');
      }
    },

    afterRender () {
      // the 'choose-what-to-sync' view is a special case view
      // where we want to hide the logo and not animate it
      // it uses `!important` to avoid the fade-in effect and inline styles.
      $('body').addClass(SCREEN_CLASS);
    },

    afterVisible () {
      this.waitForSessionVerification(
        this.getAccount(),
        () => this.validateAndSubmit()
      );

      return proto.afterVisible.call(this);
    },

    destroy (...args) {
      $('body').removeClass(SCREEN_CLASS);
      return proto.destroy.call(this, ...args);
    },

    setInitialContext (context) {
      var account = this.getAccount();
      const engines = this._getOfferedEngines();

      context.set({
        engines,
        // the below string isn't escaped, but it doesn't
        // contain anything that causes XSS.
        escapedBackLinkParams: 'id="back" href="#"',
        escapedEmail: _.escape(account.get('email')),
      });
    },

    submit () {
      const account = this.getAccount();
      const declinedSyncEngines = this._getDeclinedEngineIds();
      const offeredSyncEngines = this._getOfferedEngineIds();

      this._trackDeclinedEngineIds(declinedSyncEngines);

      account.set({
        customizeSync: true,
        declinedSyncEngines,
        offeredSyncEngines
      });

      return this.user.setAccount(account)
        .then(this.onSubmitComplete);
    },

    /**
     * Get a list of displayed Sync engine configs that can be used
     * for display.
     *
     * @returns {Object[]}
     */
    _getOfferedEngines () {
      return this.broker.get('chooseWhatToSyncWebV1Engines').toJSON().map((syncEngine, index) => {
        const engineWithTabIndex = Object.create(syncEngine);
        engineWithTabIndex.tabindex = (index + 1) * 5;
        engineWithTabIndex.text = this.translate(engineWithTabIndex.text, {});
        return engineWithTabIndex;
      });
    },

    /**
     * Get a list of engineIds that are displayed to the user.
     *
     * @returns {String[]}
     */
    _getOfferedEngineIds () {
      return this._getOfferedEngines()
        .map((syncEngine) => syncEngine.id);
    },

    /**
     * Get sync engines that were declined.
     *
     * @returns {String[]}
     * @private
     */
    _getDeclinedEngineIds () {
      var uncheckedEngineEls =
            this.$el.find('input[name=sync-content]').not(':checked');

      return uncheckedEngineEls.map(function () {
        return this.value;
      }).get();
    },

    /**
     * Keep track of what sync engines the user declines
     *
     * @param {String[]} declinedEngineIds
     * @private
     */
    _trackDeclinedEngineIds (declinedEngineIds) {
      if (_.isArray(declinedEngineIds)) {
        declinedEngineIds.forEach((engineId) => {
          this.logViewEvent(`engine-unchecked.${engineId}`);
        });
      }
    }
  }, {
    SCREEN_CLASS
  });

  Cocktail.mixin(
    View,
    BackMixin,
    CheckboxMixin,
    SessionVerificationPollMixin
  );

  module.exports = View;
});
Esempio n. 22
0
 behavior.ensureConnectAnotherDeviceMixin = function (view) {
   if (! Cocktail.isMixedIn(view, ConnectAnotherDeviceMixin)) {
     Cocktail.mixin(view, ConnectAnotherDeviceMixin);
   }
 };
Esempio n. 23
0
import Cocktail from 'cocktail';
import FormView from '../form';
import SettingsPanelMixin from '../mixins/settings-panel-mixin';
import Template from 'templates/settings/avatar.mustache';

const View = FormView.extend({
  template: Template,
  className: 'avatar',
  viewName: 'settings.avatar',

  beforeRender () {
    if (! this.supportsAvatarUpload()) {
      this.remove();
    }
  },

  onProfileUpdate () {
    this.render();
  },

  setInitialContext (context) {
    const account = this.getSignedInAccount();
    context.set('avatarDefault', account.get('profileImageUrlDefault'));
  }

});

Cocktail.mixin(View, AvatarMixin, SettingsPanelMixin);

export default View;
Esempio n. 24
0
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// This template serves as a generic landing zone for a successful pairing
// Clients and reliers can choose to show this page as part of their WebUI after the pairing flow completes

import BaseView from '../base';
import Cocktail from 'cocktail';
import PairingGraphicsMixin from '../mixins/pairing-graphics-mixin';
import Template from '../../templates/pair/success.mustache';

class PairAuthCompleteView extends BaseView {
  template = Template;

  setInitialContext (context) {
    const graphicId = this.getGraphicsId();

    context.set({ graphicId });
  }
}

Cocktail.mixin(
  PairAuthCompleteView,
  PairingGraphicsMixin
);

export default PairAuthCompleteView;
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

'use strict';

const { assert } = require('chai');
const Backbone = require('backbone');
const BaseView = require('views/base');
const Cocktail = require('cocktail');
const Notifier = require('lib/channels/notifier');
const SignedInNotificationMixin = require('views/mixins/signed-in-notification-mixin');
const sinon = require('sinon');

const View = BaseView.extend({});
Cocktail.mixin(View, SignedInNotificationMixin);

describe('views/mixins/signed-in-notification-mixin', () => {
  it('exports correct interface', () => {
    assert.lengthOf(Object.keys(SignedInNotificationMixin), 2);
    assert.isObject(SignedInNotificationMixin.notifications);
    assert.isFunction(SignedInNotificationMixin._navigateToSignedInView);
  });

  describe('new View', () => {
    let model;
    let notifier;
    let view;

    before(() => {
      model = new Backbone.Model();
define(function (require, exports, module) {
  'use strict';

  var _ = require('underscore');
  var Account = require('models/account');
  var BackMixin = require('views/mixins/back-mixin');
  var BaseView = require('views/base');
  var CheckboxMixin = require('views/mixins/checkbox-mixin');
  var Cocktail = require('cocktail');
  var FormView = require('views/form');
  var OAuthErrors = require('lib/oauth-errors');
  var PermissionTemplate = require('stache!templates/partial/permission');
  var ServiceMixin = require('views/mixins/service-mixin');
  var Strings = require('lib/strings');
  var Template = require('stache!templates/permissions');

  var t = BaseView.t;

  // Reduce the number of strings to translate by interpolating
  // to create the required variant of a label.
  var requiredPermissionLabel = t('%(permissionName)s (required)');

  // Permissions are in the array in the order they should
  // appear on the screen.
  var PERMISSIONS = [
    {
      label: t('Email address'),
      name: 'profile:email',
      required: true
    },
    {
      label: t('Display name'),
      name: 'profile:display_name'
    },
    {
      label: t('Account picture'),
      name: 'profile:avatar',
      valueVisible: false
    },
    {
      label: 'uid',
      name: 'profile:uid',
      required: true,
      visible: false
    }
  ];

  var View = FormView.extend({
    template: Template,
    className: 'permissions',

    initialize: function (options) {
      // Account data is passed in from sign up and sign in flows.
      this._account = this.user.initAccount(this.model.get('account'));

      this.type = options.type;

      // to keep the view from knowing too much about the state machine,
      // a continuation function is passed in that should be called
      // when submit has completed.
      this.onSubmitComplete = this.model.get('onSubmitComplete');
      this._validatePermissions(this.relier.get('permissions') || []);
    },

    getAccount: function () {
      return this._account;
    },

    context: function () {
      var account = this.getAccount();
      var requestedPermissions = this.relier.get('permissions');
      var applicablePermissions =
        this._getApplicablePermissions(account, requestedPermissions);
      var permissionsHTML = this._getPermissionsHTML(account, applicablePermissions);

      return {
        privacyUri: this.relier.get('privacyUri'),
        serviceName: this.relier.get('serviceName'),
        termsUri: this.relier.get('termsUri'),
        unsafePermissionsHTML: permissionsHTML,
      };
    },

    /**
     * Validate the requested permissions. Logs an INVALID_SCOPES error
     * if any invalid permissions found. Does not throw.
     *
     * @private
     * @param {string} requestedPermissionNames
     */
    _validatePermissions: function (requestedPermissionNames) {
      requestedPermissionNames.forEach(function (permissionName) {
        var permission = this._getPermissionConfig(permissionName);
        // log the invalid scope instead of throwing an error
        // to see if any reliers are specifying invalid scopes. We
        // will be more strict in the future. Ref #2508
        if (! permission) {
          this.logError(OAuthErrors.toError('INVALID_SCOPES', permissionName));
        }
      }, this);
    },

    /**
     * Get configuration for a permission
     *
     * @private
     * @param {string} permissionName
     * @returns {object} permission, if found.
     * @throws if permission is invalid
     */
    _getPermissionConfig: function (permissionName) {
      var permission = _.findWhere(PERMISSIONS, { name: permissionName });

      if (! permission) {
        return null;
      }

      return _.clone(permission);
    },

    /**
     * Get the applicable permissions. A permission is applicable
     * if both requested and the account has a corresponding value
     *
     * @private
     * @param {object} account
     * @param {array of strings} requestedPermissionNames
     * @returns {array of objects} applicable permissions
     */
    _getApplicablePermissions: function (account, requestedPermissionNames) {
      var self = this;

      // only show permissions that have corresponding values.
      var permissionsWithValues =
        account.getPermissionsWithValues(requestedPermissionNames);

      return permissionsWithValues.map(function (permissionName) {
        var permission = self._getPermissionConfig(permissionName);

        // filter out permissions we do not know about
        if (! permission) {
          return null;
        }

        return permissionName;
      }).filter(function (permissionName) {
        return permissionName !== null;
      });
    },

    /**
     * Get the index of a permission
     *
     * @private
     * @param {string} permissionName
     * @returns {number} permission index if found, -1 otw.
     */
    _getPermissionIndex: function (permissionName) {
      return _.findIndex(PERMISSIONS, function (permission) {
        return permission.name === permissionName;
      });
    },

    /**
     * Sort permissions to match the sort order in the PERMISSIONS array
     *
     * @private
     * @param {array of strings} permissionNames
     * @returns {array of strings} sorted permissionNames
     */
    _sortPermissions: function (permissionNames) {
      var self = this;
      return [].concat(permissionNames).sort(function (a, b) {
        var aIndex = self._getPermissionIndex(a);
        var bIndex = self._getPermissionIndex(b);
        return aIndex - bIndex;
      });
    },

    /**
     * Get HTML for the set of permissions
     *
     * @private
     * @param {array of strings} permissionNames
     * @returns {string} HTML
     */
    _getPermissionsHTML: function (account, permissionNames) {
      var self = this;

      var sortedPermissionNames = self._sortPermissions(permissionNames);

      // convert the permission names to HTML
      return sortedPermissionNames.map(function (permissionName) {
        var permission = self._getPermissionConfig(permissionName);
        if (permission.required !== true) {
          permission.required = false;
        }

        // convert label to the required label
        if (permission.required) {
          permission.label = Strings.interpolate(requiredPermissionLabel, {
            permissionName: permission.label
          });
        }

        // value is visible unless overridden
        if (permission.valueVisible !== false) {
          permission.valueVisible = true;
        }

        // permission as a whole is visible unless overridden
        if (permission.visible !== false) {
          permission.visible = true;
        }

        var accountKey = Account.PERMISSIONS_TO_KEYS[permissionName];
        permission.value = account.get(accountKey);

        return permission;
      }).map(PermissionTemplate).join('\n');
    },

    /**
     * Get the permission values from the form
     *
     * Returned object has the following format:
     * {
     *   'profile:display_name': false,
     *   'profile:email': true
     * }
     *
     * @private
     * @returns {object}
     */
    _getFormPermissions: function () {
      var $permissionEls = this.$('.permission');
      var clientPermissions = {};

      $permissionEls.each(function (index, el) {
        clientPermissions[el.name] = !! el.checked;
      });

      return clientPermissions;
    },

    beforeRender: function () {
      // user cannot proceed if they have not initiated a sign up/in.
      if (! this.getAccount().get('sessionToken')) {
        this.navigate(this._previousView());
        return false;
      }

      var account = this.getAccount();
      if (account.get('verified')) {
        return account.fetchProfile();
      }
    },

    submit: function () {
      var self = this;
      var account = self.getAccount();

      self.logViewEvent('accept');

      account.setClientPermissions(
          self.relier.get('clientId'), self._getFormPermissions());

      return self.user.setAccount(account)
        .then(self.onSubmitComplete);
    },

    _previousView: function () {
      var page = this.is('sign_up') ? '/signup' : '/signin';
      return this.broker.transformLink(page);
    },

    is: function (type) {
      return this.type === type;
    }
  }, {
    PERMISSIONS: PERMISSIONS
  });

  Cocktail.mixin(
    View,
    BackMixin,
    CheckboxMixin,
    ServiceMixin
  );

  module.exports = View;
});
Esempio n. 27
0
define(function (require, exports, module) {
  'use strict';

  const $ = require('jquery');
  const Backbone = require('backbone');
  const BaseView = require('views/base');
  const Cocktail = require('cocktail');
  const KeyCodes = require('lib/key-codes');
  const LoadingMixin = require('views/mixins/loading-mixin');
  const p = require('lib/promise');

  var AppView = BaseView.extend({
    initialize (options) {
      options = options || {};

      this._environment = options.environment;
      this._createView = options.createView;
    },

    notifications: {
      'show-child-view': 'showChildView',
      'show-view': 'showView'
    },

    events: {
      'click a[href^="/"]': 'onAnchorClick',
      'keyup': 'onKeyUp'
    },

    onKeyUp (event) {
      // Global listener for keyboard events. This is
      // useful for cases where the view has lost focus
      // but you still want to perform an action on that view.

      // Handle user pressing `ESC`
      if (event.which === KeyCodes.ESCAPE) {

        // Pressing ESC when any modal is visible should close the modal.
        if ($.modal.isActive()) {
          $.modal.close();
        } else if (event.currentTarget.className.indexOf('settings') >= 0) {

          // If event came from any settings view, close all panels and
          // goto base settings view.
          $('.settings-unit').removeClass('open');
          this.navigate('settings');
        }
      }
    },

    onAnchorClick (event) {
      // if someone killed this event, or the user is holding a modifier
      // key, ignore the event.
      if (event.isDefaultPrevented() ||
          event.altKey ||
          event.ctrlKey ||
          event.metaKey ||
          event.shiftKey) {
        return;
      }

      event.preventDefault();

      // Remove leading slashes
      var url = $(event.currentTarget).attr('href').replace(/^\//, '');
      if (this._environment.isFramed() && url.indexOf('legal') > -1) {
        this.window.open(url, '_blank');
        return;
      }

      this.navigate(url);
    },

    _currentView: null,

    /**
     * Show a View. If the view is already displayed the view is not
     * re-rendered. If the view is not displayed, the current view is
     * replaced.
     *
     * @param {Function} View - the View's constructor
     * @param {Object} options - options to pass to the constructor
     *
     * @returns {Promise}
     */
    showView (View, options) {
      return p().then(() => {
        options.model = options.model || new Backbone.Model();

        var currentView = this._currentView;
        if (currentView instanceof View) {
          // child view->parent view
          //
          // No need to re-render, only notify parties of the event.
          // update the current view's model with data sent from
          // the child view.
          currentView.model.set(options.model.toJSON());

          this.notifier.trigger('navigate-from-child-view', options);
          this.setTitle(currentView.titleFromView());

          return currentView;
        } else if (currentView) {
          currentView.destroy();
        }

        var viewToShow = this._createView(View, options);
        this._currentView = viewToShow;

        return viewToShow.render()
          .then((isShown) => {
            // render will return false if the view could not be
            // rendered for any reason, including if the view was
            // automatically redirected.
            if (! isShown) {
              viewToShow.destroy();

              // If viewToShow calls `navigate` in its `beforeRender` function,
              // the new view will be created and this._currentView will
              // reference the second view before the first view's render
              // promise chain completes. Ensure this._currentView is the same
              // as viewToShow before destroying the reference. Ref #3187
              if (viewToShow === this._currentView) {
                this._currentView = null;
              }

              return p(null);
            }

            this.setTitle(viewToShow.titleFromView());

            this.writeToDOM(viewToShow.el);

            // logView is done outside of the view itself because the settings
            // page renders many child views at once. If the view took care of
            // logging itself, each child view would be logged at the same time.
            // We only want to log the screen being displayed, child views will
            // be logged when they are opened.
            viewToShow.logView();

            viewToShow.afterVisible();

            this.notifier.trigger('view-shown', viewToShow);

            return viewToShow;
          });
      }).fail(this.fatalError.bind(this));
    },

    /**
     * Show a ChildView
     *
     * @param {Function} ChildView - constructor of childView to show.
     * @param {Function} ParentView - constructor of the childView's parent.
     * @param {Object} options used to create the ParentView as well as
     *        display the child view.
     *
     * @returns {Promise}
     */
    showChildView (ChildView, ParentView, options) {
      // If currentView is of the ParentView type, simply show the subView
      return p().then(() => {
        if (! (this._currentView instanceof ParentView)) {
          // Create the ParentView; its initialization method should
          // handle the childView option.
          return this.showView(ParentView, options);
        }
      })
      .then(() => this._currentView.showChildView(ChildView, options))
      .then((childView) => {
        // Use the super view's title as the base title
        var title = childView.titleFromView(this._currentView.titleFromView());
        this.setTitle(title);
        childView.logView();

        // The child view has its own model. Import the passed in
        // model data to the child's model and display any
        // necessary status messages.
        childView.model.set(options.model.toJSON());
        childView.displayStatusMessages();

        return childView;
      });
    },

    /**
     * Set the window's title
     *
     * @param {String} title
     */
    setTitle (title) {
      this.window.document.title = title;
    }

  });

  Cocktail.mixin(
    AppView,
    LoadingMixin
  );

  module.exports = AppView;
});
define(function (require, exports, module) {
  'use strict';

  var AuthErrors = require('lib/auth-errors');
  var BackMixin = require('views/mixins/back-mixin');
  var BaseView = require('views/base');
  var Cocktail = require('cocktail');
  var Constants = require('lib/constants');
  var FlowBeginMixin = require('views/mixins/flow-begin-mixin');
  var FormView = require('views/form');
  var p = require('lib/promise');
  var ResendMixin = require('views/mixins/resend-mixin');
  var ResumeTokenMixin = require('views/mixins/resume-token-mixin');
  var ServiceMixin = require('views/mixins/service-mixin');
  var SIGN_IN_REASONS = require('lib/sign-in-reasons');
  var Template = require('stache!templates/confirm_account_unlock');

  var t = BaseView.t;

  function isLockoutSourceSignIn(lockoutSource) {
    return lockoutSource === 'signin' ||
           lockoutSource === 'oauth.signin';
  }

  var View = FormView.extend({
    template: Template,
    className: 'confirm_account_unlock',

    // used by unit tests
    VERIFICATION_POLL_IN_MS: Constants.VERIFICATION_POLL_IN_MS,

    initialize: function () {
      // The password is needed to poll whether the user has
      // unlocked their account.
      this._account = this.user.initAccount(this.model.get('account'));
    },

    getAccount: function () {
      return this._account;
    },

    context: function () {
      return {
        email: this.getAccount().get('email')
      };
    },

    events: {
      // validateAndSubmit is used to prevent multiple concurrent submissions.
      'click #resend': BaseView.preventDefaultThen('validateAndSubmit')

    },

    beforeRender: function () {
      // browsing directly to the page should not be allowed.
      var self = this;
      return p().then(function () {
        if (self.getAccount().isDefault()) {
          self.navigate('signup');
          return false;
        }
      });
    },

    afterVisible: function () {
      var self = this;
      return self.broker.persistVerificationData(self.getAccount())
        .then(function () {
          return self._waitForConfirmation();
        })
        .then(function (updatedSessionData) {
          self.getAccount().set(updatedSessionData);
          self.logViewEvent('verification.success');

          // the continuation path depends on the action that triggered
          // the account lockout notice. The only time the broker should
          // be notified is if the user was trying to sign in.
          if (isLockoutSourceSignIn(self.model.get('lockoutSource'))) {
            return self.invokeBrokerMethod('afterSignIn', self.getAccount())
              .then(function () {
                self.navigate('account_unlock_complete');
              });
          }

          // return non-signin users back to where they came from.
          self.back({
            success: t('Account unlocked, please try again')
          });
        })
        .fail(function (err) {
          if (AuthErrors.is(err, 'INCORRECT_PASSWORD')) {
            // Whether the account is locked is checked before the password.
            // If the error is INCORRECT_PASSWORD, we know the account is
            // unlocked, but the user typed in their password incorrectly.
            // Boot the user back to where they came from to let them re-enter
            // their password.
            self.back({
              error: err
            });
            return;
          }
          self.displayError(err);
        });
    },

    _waitForConfirmation: function () {
      var self = this;
      var account = self.getAccount();
      var password = this.model.get('password');

      // try to sign the user in using the email/password that caused the
      // account to be locked. If the user has verified their email address,
      // the sign in will successfully complete. If they have not verified
      // their address, the sign in call will fail with the ACCOUNT_LOCKED
      // error, and we poll again.
      return account.signIn(password, self.relier, {
        reason: SIGN_IN_REASONS.ACCOUNT_UNLOCK
      })
        .fail(function (err) {
          if (AuthErrors.is(err, 'ACCOUNT_LOCKED')) {
            // user has not yet verified, poll again.
            var deferred = p.defer();

            // _waitForConfirmation will return a promise and the
            // promise chain remains unbroken.
            self.setTimeout(function () {
              deferred.resolve(self._waitForConfirmation());
            }, self.VERIFICATION_POLL_IN_MS);

            return deferred.promise;
          }

          // re-throw other errors to be handled at a higher level.
          throw err;
        });
    },

    submit: function () {
      var self = this;

      self.logViewEvent('resend');
      var email = self.getAccount().get('email');
      return self.fxaClient.sendAccountUnlockEmail(email, self.relier, {
        resume: self.getStringifiedResumeToken()
      })
      .then(function () {
        self.logViewEvent('resend.success');
        self.displaySuccess();
      });
    }
  });

  Cocktail.mixin(
    View,
    BackMixin,
    FlowBeginMixin,
    ResendMixin,
    ResumeTokenMixin,
    ServiceMixin
  );

  module.exports = View;
});
Esempio n. 29
0
    const account = this.getSignedInAccount();
    return account.checkRecoveryKeyExists()
      .then((status) => {
        if (! status.exists) {
          this.navigate('/settings/account_recovery');
        }
      });
  },

  submit() {
    const account = this.getSignedInAccount();
    return account.deleteRecoveryKey()
      .then(() => {
        this.logFlowEvent('success', this.viewName);
        this.navigate('settings/account_recovery', {
          hasRecoveryKey: false
        });
      });
  }
});

Cocktail.mixin(
  View,
  FlowEventsMixin,
  ModalSettingsPanelMixin,
  PasswordMixin
);

module.exports = View;

Esempio n. 30
0
import BaseView from 'views/base';
import Cocktail from 'cocktail';
import Metrics from 'lib/metrics';
import Notifier from 'lib/channels/notifier';
import PasswordMixin from 'views/mixins/password-mixin';
import sinon from 'sinon';
import TestHelpers from '../../../lib/helpers';
import TestTemplate from 'templates/test_template.mustache';
import WindowMock from '../../../mocks/window';

const PasswordView = BaseView.extend({
  template: TestTemplate
});

Cocktail.mixin(
  PasswordView,
  PasswordMixin
);

describe('views/mixins/password-mixin', function () {
  let metrics;
  let notifier;
  let view;
  let windowMock;

  beforeEach(function () {
    notifier = new Notifier();
    metrics = new Metrics({ notifier });
    windowMock = new WindowMock();

    view = new PasswordView({
      metrics: metrics,