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')); }); }); });
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'); }); }); }); });
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(), []); }); }); }); }); });
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); }); });
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('/')); }); }); }); });
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;
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;
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; });
behavior.ensureConnectAnotherDeviceMixin = function (view) { if (! Cocktail.isMixedIn(view, ConnectAnotherDeviceMixin)) { Cocktail.mixin(view, ConnectAnotherDeviceMixin); } };
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;
/* 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; });
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; });
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;
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,