From 39bb7715c00150b9037ce07fcc829ba4824ae376 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 25 Jul 2017 09:57:10 -0400 Subject: [PATCH] feat(emails): Add support for change email (#5242) r=shane-tomlinson,vladikoff --- app/scripts/lib/fxa-client.js | 31 +++- app/scripts/models/account.js | 49 +++++- .../models/verification/reset-password.js | 1 + .../templates/settings/emails.mustache | 31 +++- app/scripts/views/mixins/avatar-mixin.js | 9 +- app/scripts/views/settings/emails.js | 35 +++- app/styles/modules/_settings.scss | 118 +++++++++---- app/tests/spec/models/account.js | 18 +- .../models/verification/reset-password.js | 11 ++ app/tests/spec/views/settings/emails.js | 130 +++++++++++--- bower.json | 2 +- docs/query-params.md | 22 +++ tests/functional.js | 1 + tests/functional/lib/selectors.js | 21 ++- tests/functional/settings_change_email.js | 160 ++++++++++++++++++ tests/functional/settings_secondary_emails.js | 2 +- 16 files changed, 568 insertions(+), 73 deletions(-) create mode 100644 tests/functional/settings_change_email.js diff --git a/app/scripts/lib/fxa-client.js b/app/scripts/lib/fxa-client.js index f10b5a733..8a4734aea 100644 --- a/app/scripts/lib/fxa-client.js +++ b/app/scripts/lib/fxa-client.js @@ -268,6 +268,10 @@ define(function (require, exports, module) { signInOptions.skipCaseError = options.skipCaseError; } + if (options.originalLoginEmail) { + signInOptions.originalLoginEmail = options.originalLoginEmail; + } + setMetricsContext(signInOptions, options); return client.signIn(email, password, signInOptions) @@ -472,6 +476,9 @@ define(function (require, exports, module) { * @param {String} code * @param {Object} relier * @param {Object} [options={}] + * @param {String} [options.emailToHashWith] + * If specified, the password is hashed with this email address, otherwise + * the user's password is hashed with their current email. * @return {Promise} resolves when complete */ completePasswordReset: withClient((client, originalEmail, newPassword, token, code, relier, options = {}) => { @@ -488,7 +495,18 @@ define(function (require, exports, module) { return client.passwordForgotVerifyCode(code, token, passwordVerifyCodeOptions) .then(result => { - return client.accountReset(email, + let emailToHashWith = email; + + // The `emailToHashWith` option is returned by the auth-server to let the content-server + // know what to hash the new password with. This is important in the scenario where a user + // has changed their primary email address. In this case, they must still hash with the + // account's original email because this will maintain backwards compatibility with + // how account password hashing works previously. + if (options.emailToHashWith) { + emailToHashWith = trim(options.emailToHashWith); + } + + return client.accountReset(emailToHashWith, newPassword, result.accountResetToken, accountResetOptions @@ -844,7 +862,16 @@ define(function (require, exports, module) { * @param {String} sessionToken User session token * @return {Promise} resolves when complete */ - recoveryEmailSecondaryEmailEnabled: createClientDelegate('recoveryEmailSecondaryEmailEnabled') + recoveryEmailSecondaryEmailEnabled: createClientDelegate('recoveryEmailSecondaryEmailEnabled'), + + /** + * Set the new primary email address for a user. + * + * @param {String} sessionToken User session token + * @param {String} email The new primary email address + * @return {Promise} resolves when complete + */ + recoveryEmailSetPrimaryEmail: createClientDelegate('recoveryEmailSetPrimaryEmail') }; module.exports = FxaClientWrapper; diff --git a/app/scripts/models/account.js b/app/scripts/models/account.js index a496c0a42..25e6fe6ef 100644 --- a/app/scripts/models/account.js +++ b/app/scripts/models/account.js @@ -462,15 +462,15 @@ define(function (require, exports, module) { * @param {String} [options.resume] - Resume token to send * in verification email if user is unverified. * @param {String} [options.unblockCode] - Unblock code. + * @param {String} [options.originalLoginEmail] - Login used to login with originally. * @returns {Promise} - resolves when complete */ signIn (password, relier, options = {}) { + var email = this.get('email'); return p().then(() => { - var email = this.get('email'); var sessionToken = this.get('sessionToken'); - if (password) { - return this._fxaClient.signIn(email, password, relier, { + const signinOptions = { metricsContext: this._metrics.getFlowEventMetadata(), reason: options.reason || SignInReasons.SIGN_IN, resume: options.resume, @@ -478,7 +478,16 @@ define(function (require, exports, module) { // can be updated with the correct case. skipCaseError: true, unblockCode: options.unblockCode - }); + }; + + // `originalLoginEmail` is specified when the account's primary email has changed. + // This param lets the auth-server known that it should check that this email + // is the current primary for the account. + if (options.originalLoginEmail) { + signinOptions.originalLoginEmail = options.originalLoginEmail; + } + + return this._fxaClient.signIn(email, password, relier, signinOptions); } else if (sessionToken) { // We have a cached Sync session so just check that it hasn't expired. // The result includes the latest verified state @@ -488,11 +497,27 @@ define(function (require, exports, module) { } }) .then((updatedSessionData) => { + // If a different email case or primary email was used to login, + // the session won't have correct email. Update the session to use the one + // originally used for login. + if (options.originalLoginEmail && email.toLowerCase() !== options.originalLoginEmail.toLowerCase()) { + updatedSessionData.email = options.originalLoginEmail; + } + this.set(updatedSessionData); return updatedSessionData; }) .fail((err) => { + // The `INCORRECT_EMAIL_CASE` can be returned if a user is attempting to login with a different + // email case than what the account was created with or if they changed their primary email address. + // In both scenarios, the content-server needs to know the original account email to hash + // the user's password with. if (AuthErrors.is(err, 'INCORRECT_EMAIL_CASE')) { + + // Save the original email that was used for login so that the auth-server + // can verify that this is the accounts primary email address. + options.originalLoginEmail = email; + // The server will respond with the canonical email // for this account. Use it hereafter. this.set('email', err.email); @@ -1202,7 +1227,7 @@ define(function (require, exports, module) { }, /** - * Associates a new email to a users account. + * Deletes an email from a users account. * * @param {String} email * @@ -1213,6 +1238,20 @@ define(function (require, exports, module) { this.get('sessionToken'), email ); + }, + + /** + * Sets the primary email address of the user. + * + * @param {String} email + * + * @returns {Promise} + */ + setPrimaryEmail (email) { + return this._fxaClient.recoveryEmailSetPrimaryEmail( + this.get('sessionToken'), + email + ); } }, { ALLOWED_KEYS: ALLOWED_KEYS, diff --git a/app/scripts/models/verification/reset-password.js b/app/scripts/models/verification/reset-password.js index e8cd7b457..485526a1b 100644 --- a/app/scripts/models/verification/reset-password.js +++ b/app/scripts/models/verification/reset-password.js @@ -22,6 +22,7 @@ define(function (require, exports, module) { validation: { code: Vat.verificationCode().required(), email: Vat.email().required(), + emailToHashWith: Vat.email().optional(), token: Vat.token().required() } }); diff --git a/app/scripts/templates/settings/emails.mustache b/app/scripts/templates/settings/emails.mustache index 15358a9c9..5a1211c6a 100644 --- a/app/scripts/templates/settings/emails.mustache +++ b/app/scripts/templates/settings/emails.mustache @@ -28,13 +28,38 @@
{{#t}}verification required{{/t}}
{{/verified}} - +
+ {{#verified}} + {{#canChangePrimaryEmail }} + + {{/canChangePrimaryEmail }} + {{/verified}} + +
+
  • +
    + {{#verified}} + {{#canChangePrimaryEmail }} + + {{/canChangePrimaryEmail }} + {{/verified}} + +
    +
  • + {{^verified}} {{#t}}Not in inbox or spam folder? Resend?{{/t}} {{/verified}} + {{/isPrimary}} {{/emails}} diff --git a/app/scripts/views/mixins/avatar-mixin.js b/app/scripts/views/mixins/avatar-mixin.js index 7a9483481..88eb1f0e1 100644 --- a/app/scripts/views/mixins/avatar-mixin.js +++ b/app/scripts/views/mixins/avatar-mixin.js @@ -183,7 +183,14 @@ define(function (require, exports, module) { var account = this.getSignedInAccount(); account.set('displayName', displayName); return this.user.setAccount(account) - .then(_.bind(this._notifyProfileUpdate, this, account.get('uid'))); + .then(() => this._notifyProfileUpdate(account.get('uid'))); + }, + + updateDisplayEmail (email) { + var account = this.getSignedInAccount(); + account.set('email', email); + return this.user.setAccount(account) + .then(() => this._notifyProfileUpdate(account.get('uid'))); }, _notifyProfileUpdate (uid) { diff --git a/app/scripts/views/settings/emails.js b/app/scripts/views/settings/emails.js index 582df4ff8..dfe08be79 100644 --- a/app/scripts/views/settings/emails.js +++ b/app/scripts/views/settings/emails.js @@ -6,6 +6,7 @@ define(function (require, exports, module) { 'use strict'; const $ = require('jquery'); + const AvatarMixin = require('views/mixins/avatar-mixin'); const BaseView = require('views/base'); const Cocktail = require('cocktail'); const Email = require('models/email'); @@ -13,6 +14,8 @@ define(function (require, exports, module) { const FormView = require('views/form'); const preventDefaultThen = require('views/base').preventDefaultThen; const SettingsPanelMixin = require('views/mixins/settings-panel-mixin'); + const SearchParamMixin = require('lib/search-param-mixin'); + const Strings = require('lib/strings'); const showProgressIndicator = require('views/decorators/progress_indicator'); const Template = require('stache!templates/settings/emails'); @@ -30,7 +33,8 @@ define(function (require, exports, module) { events: { 'click .email-disconnect': preventDefaultThen('_onDisconnectEmail'), 'click .email-refresh.enabled': preventDefaultThen('refresh'), - 'click .resend': preventDefaultThen('resend') + 'click .resend': preventDefaultThen('resend'), + 'click .set-primary': preventDefaultThen('setPrimary') }, initialize (options) { @@ -44,6 +48,7 @@ define(function (require, exports, module) { setInitialContext (context) { context.set({ buttonClass: this._hasSecondaryEmail() ? 'secondary' : 'primary', + canChangePrimaryEmail: this._canChangePrimaryEmail(), emails: this._emails, hasSecondaryEmail: this._hasSecondaryEmail(), hasSecondaryVerifiedEmail: this._hasSecondaryVerifiedEmail(), @@ -64,6 +69,14 @@ define(function (require, exports, module) { } }, + _canChangePrimaryEmail () { + if (this.getSearchParam('canChangeEmail')) { + return true; + } + + return false; + }, + _isSecondaryEmailEnabled () { // Only show secondary email panel if the user is in a verified session and feature is enabled. const account = this.getSignedInAccount(); @@ -120,7 +133,7 @@ define(function (require, exports, module) { resend (event) { const email = $(event.currentTarget).data('id'); const account = this.getSignedInAccount(); - return account.resendEmailCode(email) + return account.resendEmailCode({ email }) .then(() => { this.displaySuccess(t('Verification email sent'), { closePanel: false @@ -143,12 +156,28 @@ define(function (require, exports, module) { .fail((err) => this.showValidationError(this.$(EMAIL_INPUT_SELECTOR), err)); } }, + + setPrimary (event) { + const email = $(event.currentTarget).data('id'); + const account = this.getSignedInAccount(); + return account.setPrimaryEmail(email) + .then(() => { + this.updateDisplayEmail(email); + this.displaySuccess(Strings.interpolate(t('Primary email set to %(email)s'), { email }), { + closePanel: false + }); + this.render(); + }); + } + }); Cocktail.mixin( View, + AvatarMixin, SettingsPanelMixin, - FloatingPlaceholderMixin + FloatingPlaceholderMixin, + SearchParamMixin ); module.exports = View; diff --git a/app/styles/modules/_settings.scss b/app/styles/modules/_settings.scss index ad723f82a..2fc5481e9 100644 --- a/app/styles/modules/_settings.scss +++ b/app/styles/modules/_settings.scss @@ -655,67 +655,121 @@ section.modal-panel { } .email-list { - padding: 0; -} + padding: 0; -.email-address { + .email-address { height: 40px; list-style: none; margin: 10px 0; position: relative; html[dir='ltr'] & { - background-position: left 2px; + background-position: left 2px; } html[dir='rtl'] & { - background-position: right 2px; + background-position: right 2px; } .address { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: calc(95% - 95px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: calc(95% - 95px); } .details { - color: $color-grey; - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: calc(95% - 95px); + color: $color-grey; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: calc(95% - 95px); - & .not-verified { - color: $color-red; - } + & .not-verified { + color: $color-red; + } - & .verified { - color: $color-green; - } + & .verified { + color: $color-green; + } } - .settings-button { + @media screen and (max-width: 400px) { + .settings-button-group { + /*!important is used here because on small screens these options are displayed*/ + /*in another area of the panel. They are to the right of the email.*/ + display: none !important; + } + } + + .settings-button-group { + position: absolute; + top: 0; + + html[dir='ltr'] & { + right: 0; + } + + html[dir='rtl'] & { + left: 0; + } + + .settings-button { height: 35px; + margin-left: 10px; /*minimum width required for the button to look good without occupying too much space*/ /*is also the default computed width on desktop screen*/ min-width: 100px; - position: absolute; text-align: center; - top: 0; width: 20%; + } + } + } - html[dir='ltr'] & { - right: 0; - } + @media screen and (min-width: 400px) { + .email-options { + /*!important is used here because on large screens these options are not displayed.*/ + /*They sit below the email address.*/ + display: none !important; + } + } - html[dir='rtl'] & { - left: 0; - } + .email-options { + height: 40px; + list-style: none; + margin: 10px 0; + position: relative; + + html[dir='ltr'] & { + background-position: left 2px; } - a.settings-button { - padding-top: 8px; + html[dir='rtl'] & { + background-position: right 2px; } + + .settings-button-group { + position: absolute; + top: 0; + + html[dir='ltr'] & { + left: 0; + } + + html[dir='rtl'] & { + right: 0; + } + + .settings-button { + height: 35px; + margin-left: 10px; + /*minimum width required for the button to look good without occupying too much space*/ + /*is also the default computed width on desktop screen*/ + min-width: 100px; + text-align: center; + width: 20%; + } + } + } } diff --git a/app/tests/spec/models/account.js b/app/tests/spec/models/account.js index 245f533b4..a170b0381 100644 --- a/app/tests/spec/models/account.js +++ b/app/tests/spec/models/account.js @@ -488,7 +488,7 @@ define(function (require, exports, module) { }); it('re-tries with the normalized email, updates model with normalized email', function () { - const expectedOptions = { + const firstExpectedOptions = { metricsContext: { baz: 'qux', foo: 'bar' @@ -499,11 +499,23 @@ define(function (require, exports, module) { unblockCode: 'unblock code' }; + const secondExpectedOptions = { + metricsContext: { + baz: 'qux', + foo: 'bar' + }, + originalLoginEmail: upperCaseEmail, + reason: SignInReasons.SIGN_IN, + resume: 'resume token', + skipCaseError: true, + unblockCode: 'unblock code' + }; + assert.equal(fxaClient.signIn.callCount, 2); assert.isTrue( - fxaClient.signIn.calledWith(upperCaseEmail, PASSWORD, relier, expectedOptions)); + fxaClient.signIn.calledWith(upperCaseEmail, PASSWORD, relier, firstExpectedOptions)); assert.isTrue( - fxaClient.signIn.calledWith(EMAIL, PASSWORD, relier, expectedOptions)); + fxaClient.signIn.calledWith(EMAIL, PASSWORD, relier, secondExpectedOptions)); assert.equal(account.get('email'), EMAIL); }); diff --git a/app/tests/spec/models/verification/reset-password.js b/app/tests/spec/models/verification/reset-password.js index 0ea93c3bc..b75b5f859 100644 --- a/app/tests/spec/models/verification/reset-password.js +++ b/app/tests/spec/models/verification/reset-password.js @@ -78,6 +78,17 @@ define(function (require, exports, module) { assert.isFalse(model.isValid()); }); + it('returns false if emailToHasWith is invalid', function () { + var model = new Model({ + code: validCode, + email: validEmail, + emailToHashWith: invalidEmail, + token: validToken + }); + + assert.isFalse(model.isValid()); + }); + it('returns true otherwise', function () { var model = new Model({ code: validCode, diff --git a/app/tests/spec/views/settings/emails.js b/app/tests/spec/views/settings/emails.js index ab03b1474..1d8d0e961 100644 --- a/app/tests/spec/views/settings/emails.js +++ b/app/tests/spec/views/settings/emails.js @@ -57,6 +57,7 @@ define(function (require, exports, module) { translator = new Translator({forceEnglish: true}); user = new User(); windowMock = new WindowMock(); + windowMock.location.search = '?canChangeEmail=true'; account = user.initAccount({ email: email, @@ -148,6 +149,11 @@ define(function (require, exports, module) { sinon.stub(account, 'resendEmailCode', () => { return p(); }); + + sinon.stub(account, 'setPrimaryEmail', (newEmail) => { + email = newEmail; + return p(); + }); }); describe('with no secondary email', () => { @@ -197,21 +203,24 @@ define(function (require, exports, module) { it('can render', () => { assert.equal(view.$('.email-address').length, 1); - assert.equal(view.$('.email-address .address').length, 1); - assert.equal(view.$('.email-address .address')[0].innerHTML, 'another@one.com'); + assert.lengthOf(view.$('.email-address .address'), 1); + assert.equal(view.$('.email-address .address').html(), 'another@one.com'); assert.equal(view.$('.email-address .details .not-verified').length, 1); assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').length, 1); assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').attr('data-id'), 'another@one.com'); + assert.equal(view.$('.email-address .settings-button.secondary.set-primary').length, 0); + }); it('can disconnect email and navigate to /emails', (done) => { $('.email-address .settings-button.warning.email-disconnect').click(); setTimeout(function () { - assert.isTrue(view.navigate.calledOnce); - const args = view.navigate.args[0]; - assert.equal(args.length, 1); - assert.equal(args[0], '/settings/emails'); - done(); + TestHelpers.wrapAssertion(() => { + assert.isTrue(view.navigate.calledOnce); + const args = view.navigate.args[0]; + assert.equal(args.length, 1); + assert.equal(args[0], '/settings/emails'); + }, done); }, 150); }); @@ -219,8 +228,9 @@ define(function (require, exports, module) { $('.email-refresh').click(); sinon.spy(view, 'render'); setTimeout(function () { - assert.isTrue(view.render.calledOnce); - done(); + TestHelpers.wrapAssertion(() => { + assert.isTrue(view.render.calledOnce); + }, done); }, 450); // Delay is higher here because refresh has a min delay of 350 }); @@ -228,11 +238,12 @@ define(function (require, exports, module) { $('.resend').click(); sinon.spy(view, 'render'); setTimeout(function () { - assert.isTrue(view.navigate.calledOnce); - const args = view.navigate.args[0]; - assert.equal(args.length, 1); - assert.equal(args[0], '/settings/emails'); - done(); + TestHelpers.wrapAssertion(() => { + assert.isTrue(view.navigate.calledOnce); + const args = view.navigate.args[0]; + assert.equal(args.length, 1); + assert.equal(args[0], '/settings/emails'); + }, done); }, 150); }); @@ -263,8 +274,8 @@ define(function (require, exports, module) { it('can render', () => { assert.equal(view.$('.email-address').length, 1); - assert.equal(view.$('.email-address .address').length, 1); - assert.equal(view.$('.email-address .address')[0].innerHTML, 'another@one.com'); + assert.lengthOf(view.$('.email-address .address'), 1); + assert.equal(view.$('.email-address .address').html(), 'another@one.com'); assert.equal(view.$('.email-address .details .verified').length, 1); assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').length, 1); assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').attr('data-id'), 'another@one.com'); @@ -273,11 +284,12 @@ define(function (require, exports, module) { it('can disconnect email and navigate to /emails', (done) => { $('.email-address .settings-button.warning.email-disconnect').click(); setTimeout(() => { - assert.isTrue(view.navigate.calledOnce); - const args = view.navigate.args[0]; - assert.equal(args.length, 1); - assert.equal(args[0], '/settings/emails'); - done(); + TestHelpers.wrapAssertion(() => { + assert.isTrue(view.navigate.calledOnce); + const args = view.navigate.args[0]; + assert.equal(args.length, 1); + assert.equal(args[0], '/settings/emails'); + }, done); }, 150); }); @@ -285,6 +297,82 @@ define(function (require, exports, module) { assert.equal(view.isPanelOpen(), false); }); }); + + describe('does not show change email when `canChangeEmail` not set', () => { + const newEmail = 'secondary@email.com'; + beforeEach(() => { + emails = [{ + email: 'primary@email.com', + isPrimary: true, + verified: true + }, { + email: newEmail, + isPrimary: false, + verified: true + }]; + + windowMock.location.search = ''; + + return initView() + .then(function () { + // click events require the view to be in the DOM + $('#container').html(view.el); + }); + }); + + it('can render', () => { + assert.equal(view.$('.email-address').length, 1); + assert.lengthOf(view.$('.email-address .address'), 1); + assert.equal(view.$('.email-address .address').html(), 'secondary@email.com'); + assert.equal(view.$('.email-address .details .verified').length, 1); + assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').length, 1); + assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').attr('data-id'), 'secondary@email.com'); + assert.equal(view.$('.email-address .settings-button.secondary.set-primary').length, 0); + }); + }); + + + describe('can change email', () => { + const newEmail = 'secondary@email.com'; + beforeEach(() => { + emails = [{ + email: 'primary@email.com', + isPrimary: true, + verified: true + }, { + email: newEmail, + isPrimary: false, + verified: true + }]; + + return initView() + .then(function () { + // click events require the view to be in the DOM + $('#container').html(view.el); + }); + }); + + it('can render', () => { + assert.equal(view.$('.email-address').length, 1); + assert.lengthOf(view.$('.email-address .address'), 1); + assert.equal(view.$('.email-address .address').html(), 'secondary@email.com'); + assert.equal(view.$('.email-address .details .verified').length, 1); + assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').length, 1); + assert.equal(view.$('.email-address .settings-button.warning.email-disconnect').attr('data-id'), 'secondary@email.com'); + assert.equal(view.$('.email-address .settings-button.secondary.set-primary').length, 1); + assert.equal(view.$('.email-address .settings-button.secondary.set-primary').attr('data-id'), 'secondary@email.com'); + }); + + it('can change email', (done) => { + $('.email-address .settings-button.secondary.set-primary').click(); + setTimeout(() => { + TestHelpers.wrapAssertion(() => { + assert.equal(account.get('email'), newEmail, 'account email updated'); + }, done); + }, 150); + }); + }); + }); }); }); diff --git a/bower.json b/bower.json index 2cf8dde0d..3323fdd50 100644 --- a/bower.json +++ b/bower.json @@ -10,7 +10,7 @@ "cocktail": "0.5.9", "easteregg": "https://github.com/mozilla/fxa-easter-egg.git#ab20cd517cf8ae9feee115e48745189d28e13bc3", "fxa-checkbox": "mozilla/fxa-checkbox#7f856afffd394a144f718e28e6fb79092d6ccddd", - "fxa-js-client": "https://github.com/mozilla/fxa-js-client.git#0.1.60", + "fxa-js-client": "https://github.com/mozilla/fxa-js-client.git#0.1.61", "html5shiv": "3.7.2", "jquery": "3.1.0", "jquery-modal": "https://github.com/shane-tomlinson/jquery-modal.git#0576775d1b4590314b114386019f4c7421c77503", diff --git a/docs/query-params.md b/docs/query-params.md index b7ea55aa2..9dc5dff15 100644 --- a/docs/query-params.md +++ b/docs/query-params.md @@ -340,3 +340,25 @@ Used to skip the confirmation form to reset a password #### When to use Should not be used by reliers. Should only be used for accounts that must be reset. + +### `emailToHashWith` +Allows you to override the default email that a reset password is hashed with. + +#### Options +* user's current primary email (default) + +#### When to use +After a user has changed their primary email you need to hash with the original account email +if they perform a reset password. + +## Secondary email parameters + +### `canChangeEmail` +Shows the option to change a user's primary email address. + +#### Options +* `true` +* `false` (default) + +#### When to specify +* /settings/emails diff --git a/tests/functional.js b/tests/functional.js index 38ae933a0..b74e12935 100644 --- a/tests/functional.js +++ b/tests/functional.js @@ -50,6 +50,7 @@ define([ './functional/settings', './functional/settings_clients', './functional/settings_common', + './functional/settings_change_email.js', './functional/settings_secondary_emails.js', './functional/sync_settings', './functional/change_password', diff --git a/tests/functional/lib/selectors.js b/tests/functional/lib/selectors.js index 328727023..d4f8f34d4 100644 --- a/tests/functional/lib/selectors.js +++ b/tests/functional/lib/selectors.js @@ -14,6 +14,9 @@ define([], function () { ERROR: '.error', HEADER: '#fxa-400-header' }, + CHANGE_PASSWORD: { + MENU_BUTTON: '#change-password .settings-unit-toggle' + }, CHOOSE_WHAT_TO_SYNC: { ENGINE_ADDRESSES: '#sync-engine-addresses', ENGINE_CREDIT_CARDS: '#sync-engine-creditcards', @@ -22,6 +25,12 @@ define([], function () { HEADER: '#fxa-choose-what-to-sync-header', SUBMIT: 'button[type=submit]' }, + COMPLETE_RESET_PASSWORD: { + HEADER: '#fxa-complete-reset-password-header' + }, + CONFIRM_RESET_PASSWORD: { + HEADER: '#fxa-confirm-reset-password-header' + }, CONFIRM_SIGNIN: { HEADER: '#fxa-confirm-signin-header' }, @@ -31,6 +40,15 @@ define([], function () { CONNECT_ANOTHER_DEVICE: { HEADER: '#fxa-connect-another-device-header' }, + EMAIL: { + ADDRESS_LABEL: '#emails .address', + ADD_BUTTON: '.email-add:not(.disabled)', + INPUT: '.new-email', + MENU_BUTTON: '#emails .settings-unit-stub button', + NOT_VERIFIED_LABEL: '.not-verified', + SET_PRIMARY_EMAIL_BUTTON: '.email-address .set-primary', + VERIFIED_LABEL: '.verified' + }, FORCE_AUTH: { EMAIL: 'input[type=email]', HEADER: '#fxa-force-auth-header' @@ -49,7 +67,8 @@ define([], function () { EMAIL_NOT_EDITABLE: '.prefillEmail', HEADER: '#fxa-signin-header', PASSWORD: 'input[type=password]', - SUBMIT: 'button[type=submit]' + SUBMIT: 'button[type=submit]', + TOOLTIP: '.tooltip', }, SIGNIN_COMPLETE: { HEADER: '#fxa-sign-in-complete-header' diff --git a/tests/functional/settings_change_email.js b/tests/functional/settings_change_email.js new file mode 100644 index 000000000..674099fe1 --- /dev/null +++ b/tests/functional/settings_change_email.js @@ -0,0 +1,160 @@ +/* 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'; + +define([ + 'intern', + 'intern!object', + 'tests/lib/helpers', + 'tests/functional/lib/helpers', + 'tests/functional/lib/selectors', +], function (intern, registerSuite, TestHelpers, FunctionalHelpers, selectors) { + + const config = intern.config; + + const SIGNUP_URL = config.fxaContentRoot + 'signup?canChangeEmail=true'; + const SIGNIN_URL = config.fxaContentRoot + 'signin?canChangeEmail=true'; + const SIGNIN_URL_NO_CHANGE_EMAIL = config.fxaContentRoot + 'signin'; + const PASSWORD = 'password'; + const NEW_PASSWORD = 'password1'; + + let email; + let secondaryEmail; + + const clearBrowserState = FunctionalHelpers.clearBrowserState; + const click = FunctionalHelpers.click; + const fillOutChangePassword = FunctionalHelpers.fillOutChangePassword; + const fillOutResetPassword = FunctionalHelpers.fillOutResetPassword; + const fillOutCompleteResetPassword = FunctionalHelpers.fillOutCompleteResetPassword; + const fillOutSignUp = FunctionalHelpers.fillOutSignUp; + const fillOutSignIn = FunctionalHelpers.fillOutSignIn; + const openPage = FunctionalHelpers.openPage; + const openVerificationLinkInNewTab = FunctionalHelpers.openVerificationLinkInNewTab; + const openVerificationLinkInSameTab = FunctionalHelpers.openVerificationLinkInSameTab; + const noSuchElement = FunctionalHelpers.noSuchElement; + const testIsBrowserNotified = FunctionalHelpers.testIsBrowserNotified; + const testElementExists = FunctionalHelpers.testElementExists; + const testElementTextEquals = FunctionalHelpers.testElementTextEquals; + const testErrorTextInclude = FunctionalHelpers.testErrorTextInclude; + const testSuccessWasShown = FunctionalHelpers.testSuccessWasShown; + const type = FunctionalHelpers.type; + const visibleByQSA = FunctionalHelpers.visibleByQSA; + + registerSuite({ + name: 'settings change email', + + beforeEach: function () { + email = TestHelpers.createEmail(); + secondaryEmail = TestHelpers.createEmail(); + return this.remote.then(clearBrowserState()) + .then(openPage(SIGNUP_URL, selectors.SIGNUP.HEADER)) + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + .then(openVerificationLinkInSameTab(email, 0)) + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(click(selectors.EMAIL.MENU_BUTTON)) + + // add secondary email, verify + .then(type(selectors.EMAIL.INPUT, secondaryEmail)) + .then(click(selectors.EMAIL.ADD_BUTTON)) + .then(testElementExists(selectors.EMAIL.NOT_VERIFIED_LABEL)) + .then(openVerificationLinkInSameTab(secondaryEmail, 0)) + + .then(click(selectors.SETTINGS.SIGNOUT)) + .then(openPage(SIGNIN_URL, selectors.SIGNIN.HEADER)) + .then(fillOutSignIn(email, PASSWORD)) + + // set new primary email + .then(click(selectors.EMAIL.MENU_BUTTON )) + .then(testElementTextEquals(selectors.EMAIL.ADDRESS_LABEL, secondaryEmail)) + .then(testElementExists(selectors.EMAIL.VERIFIED_LABEL)) + .then(click(selectors.EMAIL.SET_PRIMARY_EMAIL_BUTTON)); + }, + + afterEach: function () { + return this.remote.then(clearBrowserState()); + }, + + 'does no show change email option if query `canChangeEmail` not set': function () { + return this.remote + // sign out + .then(click(selectors.SETTINGS.SIGNOUT)) + + // sign in and does not show change primary email button + .then(openPage(SIGNIN_URL_NO_CHANGE_EMAIL, selectors.SIGNIN.HEADER)) + .then(testElementExists(selectors.SIGNIN.HEADER)) + .then(fillOutSignIn(secondaryEmail, PASSWORD)) + .then(click(selectors.EMAIL.MENU_BUTTON )) + .then(noSuchElement(selectors.EMAIL.SET_PRIMARY_EMAIL_BUTTON)); + }, + + 'can change primary email and login': function () { + return this.remote + // sign out + .then(click(selectors.SETTINGS.SIGNOUT)) + + // sign in with old primary email fails + .then(openPage(SIGNIN_URL, selectors.SIGNIN.HEADER)) + .then(testElementExists(selectors.SIGNIN.HEADER)) + .then(fillOutSignIn(email, PASSWORD)) + .then(testErrorTextInclude('Primary account email required')) + + // sign in with new primary email + .then(testElementExists(selectors.SIGNIN.HEADER)) + .then(fillOutSignIn(secondaryEmail, PASSWORD)) + + // shows new primary email + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(testElementTextEquals(selectors.SETTINGS.PROFILE_HEADER, secondaryEmail)); + }, + + 'can change primary email, change password and login': function () { + return this.remote + // change password + .then(click(selectors.CHANGE_PASSWORD.MENU_BUTTON)) + .then(fillOutChangePassword(PASSWORD, NEW_PASSWORD)) + .then(testIsBrowserNotified('fxaccounts:change_password')) + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(testSuccessWasShown()) + + // sign out and fails login with old password + .then(click(selectors.SETTINGS.SIGNOUT)) + .then(testElementExists(selectors.SIGNIN.HEADER)) + .then(fillOutSignIn(secondaryEmail, PASSWORD)) + .then(visibleByQSA(selectors.SIGNIN.TOOLTIP)) + + // sign in with new password + .then(fillOutSignIn(secondaryEmail, NEW_PASSWORD)) + .then(testElementTextEquals(selectors.SETTINGS.PROFILE_HEADER, secondaryEmail)); + }, + + 'can change primary email, reset password and login': function () { + return this.remote + .then(click(selectors.SETTINGS.SIGNOUT)) + + // reset password + .then(fillOutResetPassword(secondaryEmail)) + .then(testElementExists(selectors.CONFIRM_RESET_PASSWORD.HEADER)) + .then(openVerificationLinkInNewTab(secondaryEmail, 1)) + + // complete the reset password in the new tab + .switchToWindow('newwindow') + .then(testElementExists(selectors.COMPLETE_RESET_PASSWORD.HEADER)) + .then(fillOutCompleteResetPassword(NEW_PASSWORD, NEW_PASSWORD)) + + .then(testElementTextEquals(selectors.SETTINGS.PROFILE_HEADER, secondaryEmail)) + + // sign out and fails login with old password + .then(click(selectors.SETTINGS.SIGNOUT)) + .then(testElementExists(selectors.SIGNIN.HEADER)) + .then(fillOutSignIn(secondaryEmail, PASSWORD)) + .then(visibleByQSA(selectors.SIGNIN.TOOLTIP)) + + // sign in with new password succeeds + .then(fillOutSignIn(secondaryEmail, NEW_PASSWORD)) + .then(testElementTextEquals(selectors.SETTINGS.PROFILE_HEADER, secondaryEmail)); + } + }); +}); diff --git a/tests/functional/settings_secondary_emails.js b/tests/functional/settings_secondary_emails.js index 89d881052..e8a7edb78 100644 --- a/tests/functional/settings_secondary_emails.js +++ b/tests/functional/settings_secondary_emails.js @@ -76,7 +76,7 @@ define([ .then(type('.new-email', TestHelpers.createEmail())) .then(click('.email-add:not(.disabled)')) .then(testElementExists('.not-verified')) - .then(click('.email-disconnect')) + .then(click('.email-address .settings-button.warning.email-disconnect')) // add secondary email, verify .then(type('.new-email', secondaryEmail))