feat(emails): UX for additional emails r=vladikoff,vbudhram,shane-tomlinson

Fixes #4756
This commit is contained in:
Vlad Filippov 2017-05-15 18:08:57 -04:00 коммит произвёл GitHub
Родитель 574ecb4e01
Коммит 314e593ea2
24 изменённых файлов: 1112 добавлений и 14 удалений

Просмотреть файл

@ -145,6 +145,36 @@ define(function (require, exports, module) {
errno: 135,
message: t('Unable to deliver email')
},
// Secondary Email errors
EMAIL_EXISTS: {
errno: 136,
message: t('This email was already verified by another user')
},
EMAIL_PRIMARY_EXISTS: {
errno: 139,
message: t('Secondary email must be different than your account email')
},
EMAIL_VERIFIED_PRIMARY_EXISTS: {
errno: 140,
message: t('Account already exists')
},
UNVERIFIED_PRIMARY_EMAIL_NEWLY_CREATED: {
errno: 141,
message: t('Account already exists')
},
LOGIN_WITH_SECONDARY_EMAIL: {
errno: 142,
message: t('Primary account email required for sign-in')
},
VERIFIED_SECONDARY_EMAIL_EXISTS: {
errno: 144,
message: t('Address in use by another account')
},
RESET_PASSWORD_WITH_SECONDARY_EMAIL: {
errno: 145,
message: t('Primary account email required for reset')
},
// Secondary Email errors end
SERVER_BUSY: {
errno: 201,
message: t('Server busy, try again soon')

Просмотреть файл

@ -565,6 +565,21 @@ define(function (require, exports, module) {
});
}),
/**
* This function gets the status of the user's sessionToken.
* It differs from `recoveryEmailStatus` because it also returns
* `sessionVerified`, which gives the true state of the sessionToken.
*
* Note that a session is considered verified if it has gone through
* an email verification loop.
*
* @param {String} sessionToken
* @returns {Promise} resolves with response when complete.
*/
sessionVerificationStatus: withClient(function (client, sessionToken) {
return client.recoveryEmailStatus(sessionToken);
}),
accountKeys: withClient((client, keyFetchToken, unwrapBKey) => {
return client.accountKeys(keyFetchToken, unwrapBKey);
}),
@ -651,6 +666,22 @@ define(function (require, exports, module) {
});
}),
recoveryEmails: withClient((client, sessionToken) => {
return client.recoveryEmails(sessionToken);
}),
recoveryEmailCreate: withClient((client, sessionToken, email) => {
return client.recoveryEmailCreate(sessionToken, email);
}),
recoveryEmailDestroy: withClient((client, sessionToken, email) => {
return client.recoveryEmailDestroy(sessionToken, email);
}),
resendEmailCode: withClient((client, sessionToken, email) => {
return client.recoveryEmailResendCode(sessionToken, {email: email});
}),
/**
* Check whether SMS is enabled for the user
*
@ -663,6 +694,10 @@ define(function (require, exports, module) {
*/
smsStatus: withClient((client, sessionToken, options) => {
return client.smsStatus(sessionToken, options);
}),
deleteEmail: withClient((client, sessionToken, email) => {
return client.deleteEmail(sessionToken, email);
})
};

Просмотреть файл

@ -26,6 +26,7 @@ define(function (require, exports, module) {
const CookiesDisabledView = require('../views/cookies_disabled');
const DeleteAccountView = require('../views/settings/delete_account');
const DisplayNameView = require('../views/settings/display_name');
const EmailsView = require('../views/settings/emails');
const ForceAuthView = require('../views/force_auth');
const IndexView = require('../views/index');
const LegalView = require('../views/legal');
@ -89,6 +90,7 @@ define(function (require, exports, module) {
'reset_password(/)': createViewHandler(ResetPasswordView),
'reset_password_confirmed(/)': createViewHandler(ReadyView, { type: VerificationReasons.PASSWORD_RESET }),
'reset_password_verified(/)': createViewHandler(ReadyView, { type: VerificationReasons.PASSWORD_RESET }),
'secondary_email_verified(/)': createViewHandler(ReadyView, { type: VerificationReasons.SECONDARY_EMAIL_VERIFIED }),
'settings(/)': createViewHandler(SettingsView),
'settings/avatar/camera(/)': createChildViewHandler(AvatarCameraView, SettingsView),
'settings/avatar/change(/)': createChildViewHandler(AvatarChangeView, SettingsView),
@ -99,6 +101,7 @@ define(function (require, exports, module) {
'settings/communication_preferences(/)': createChildViewHandler(CommunicationPreferencesView, SettingsView),
'settings/delete_account(/)': createChildViewHandler(DeleteAccountView, SettingsView),
'settings/display_name(/)': createChildViewHandler(DisplayNameView, SettingsView),
'settings/emails(/)': createChildViewHandler(EmailsView, SettingsView),
'signin(/)': createViewHandler(SignInView),
'signin_confirmed(/)': createViewHandler(ReadyView, { type: VerificationReasons.SIGN_IN }),
'signin_permissions(/)': createViewHandler(PermissionsView, { type: VerificationReasons.SIGN_IN }),
@ -112,7 +115,8 @@ define(function (require, exports, module) {
'sms(/)': createViewHandler(SmsSendView),
'sms/sent(/)': createViewHandler(SmsSentView),
'sms/why(/)': createChildViewHandler(WhyConnectAnotherDeviceView, SmsSendView),
'verify_email(/)': createViewHandler(CompleteSignUpView, { type: VerificationReasons.SIGN_UP })
'verify_email(/)': createViewHandler(CompleteSignUpView, { type: VerificationReasons.SIGN_UP }),
'verify_secondary_email(/)': createViewHandler(CompleteSignUpView, { type: VerificationReasons.SECONDARY_EMAIL_VERIFIED })
},
initialize (options = {}) {

Просмотреть файл

@ -12,6 +12,7 @@ define(function (require, exports, module) {
return {
FORCE_AUTH: 'force_auth',
PASSWORD_RESET: 'reset_password',
SECONDARY_EMAIL_VERIFIED: 'secondary_email_verified',
SIGN_IN: 'login',
SIGN_UP: 'signup'
};

Просмотреть файл

@ -262,6 +262,43 @@ define(function (require, exports, module) {
});
},
/**
* This function simply returns the session status of the user. It differs
* from `sessionStatus` function above because it is not used to determine
* which view to take a user after the login. This function also does not
* have the restriction to be backwards compatible to legacy clients.
*
* @returns {Promise} resolves with the account's current session
* information if session is valid. Rejects with an INVALID_TOKEN error
* if session is invalid.
*
* Session information:
* {
* email: <canonicalized email>,
* verified: <boolean>
* emailVerified: <boolean>
* sessionVerified: <boolean>
* }
*/
sessionVerificationStatus () {
const sessionToken = this.get('sessionToken');
if (! sessionToken) {
return p.reject(AuthErrors.toError('INVALID_TOKEN'));
}
return this._fxaClient.sessionVerificationStatus(sessionToken)
.then((resp) => {
return resp;
}, (err) => {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
// sessionToken is no longer valid, kill it.
this.unset('sessionToken');
}
throw err;
});
},
/**
* Wait for the session to become verified.
*
@ -1035,6 +1072,98 @@ define(function (require, exports, module) {
}
return this._fxaClient.smsStatus(sessionToken, options);
},
/**
* Get emails associated with user.
*
* @returns {Promise}
*/
recoveryEmails () {
return this._fxaClient.recoveryEmails(
this.get('sessionToken')
);
},
/**
* Associates a new email to a user's account.
*
* @param {String} email
*
* @returns {Promise}
*/
recoveryEmailCreate (email) {
return this._fxaClient.recoveryEmailCreate(
this.get('sessionToken'),
email
);
},
/**
* Deletes email from user's account.
*
* @param {String} email
*
* @returns {Promise}
*/
recoveryEmailDestroy (email) {
return this._fxaClient.recoveryEmailDestroy(
this.get('sessionToken'),
email
);
},
/**
* Resend the verification code associated with the passed email address
*
* @param {String} email
*
* @returns {Promise}
*/
resendEmailCode (email) {
return this._fxaClient.resendEmailCode(
this.get('sessionToken'),
email
);
},
/**
* Get emails associated with user.
*
* @returns {Promise}
*/
getEmails () {
return this._fxaClient.getEmails(
this.get('sessionToken')
);
},
/**
* Associates a new email to a users account.
*
* @param {String} email
*
* @returns {Promise}
*/
createEmail (email) {
return this._fxaClient.createEmail(
this.get('sessionToken'),
email
);
},
/**
* Associates a new email to a users account.
*
* @param {String} email
*
* @returns {Promise}
*/
deleteEmail (email) {
return this._fxaClient.deleteEmail(
this.get('sessionToken'),
email
);
}
}, {
ALLOWED_KEYS: ALLOWED_KEYS,

Просмотреть файл

@ -0,0 +1,24 @@
/* 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/. */
/**
* Email information
*/
define(function (require, exports, module) {
'use strict';
const Backbone = require('backbone');
var Email = Backbone.Model.extend({
defaults: {
email: null,
isPrimary: false,
verified: false
},
});
module.exports = Email;
});

Просмотреть файл

@ -16,6 +16,7 @@ define(function (require, exports, module) {
defaults: {
code: null,
reminder: null,
type: null,
uid: null
},

Просмотреть файл

@ -17,7 +17,12 @@
<p class="account-ready-service">{{#t}}You are now ready to use %(serviceName)s{{/t}}</p>
{{/serviceName}}
{{^serviceName}}
{{#secondaryEmailVerified}}
<p class="account-ready-service">{{#t}}Account notifications will now also be sent to %(secondaryEmailVerified)s{{/t}}</p>
{{/secondaryEmailVerified}}
{{^secondaryEmailVerified}}
<p class="account-ready-generic">{{#t}}Your account is ready!{{/t}}</p>
{{/secondaryEmailVerified}}
{{/serviceName}}
{{/isSync}}

Просмотреть файл

@ -0,0 +1,69 @@
<div id="emails" class="settings-unit {{#isPanelOpen}}open{{/isPanelOpen}}">
<div class="settings-unit-stub">
<header class="settings-unit-summary">
<h2 class="settings-unit-title">{{#t}}Secondary email{{/t}}</h2>
</header>
<button class="settings-button settings-unit-toggle hidden {{buttonClass}}" data-href="/settings/emails">
{{#hasSecondaryEmail}}{{#t}}Change…{{/t}}{{/hasSecondaryEmail}}
{{^hasSecondaryEmail}}{{#t}}Add…{{/t}}{{/hasSecondaryEmail}}
</button>
</div>
<div class="settings-unit-details">
<form novalidate>
<p>
{{#t}}A secondary email is an additional address for receiving security notices and confirming new Sync devices.{{/t}}
</p>
{{#hasSecondaryEmail}}
<ul class="email-list button-row">
{{#emails}}
{{^isPrimary}}
<li class="email-address">
<div class="address">{{email}}</div>
<div class="details">
{{#verified}}
<div class="verified">{{#t}}verified{{/t}}</div>
{{/verified}}
{{^verified}}
<div class="not-verified">{{#t}}verification required{{/t}}</div>
{{/verified}}
</div>
<button class="settings-button warning email-disconnect" data-id="{{email}}">
{{#t}}Remove{{/t}}
</button>
</li>
{{^verified}}
<a class="resend" data-id="{{email}}">{{#t}}Didn't arrive and not in spam folder? Resend?{{/t}}</a>
{{/verified}}
{{/isPrimary}}
{{/emails}}
</ul>
{{/hasSecondaryEmail}}
{{^hasSecondaryEmail}}
<div class="input-row">
<label class="label-helper"></label>
<input type="email" class="new-email tooltip-below" placeholder="{{#t}}Secondary email{{/t}}"
value="{{newEmail}}" autofocus autocomplete="off"/>
</div>
{{/hasSecondaryEmail}}
<div class="button-row">
{{#hasSecondaryEmail}}
<button class="settings-button email-refresh primary enabled">{{#t}}Refresh{{/t}}</button>
<button
class="settings-button {{#hasSecondaryVerifiedEmail}}cancel secondary enabled{{/hasSecondaryVerifiedEmail}}
{{^hasSecondaryVerifiedEmail}} secondary-disabled disabled {{/hasSecondaryVerifiedEmail}}">
{{#t}}Done{{/t}}
</button>
{{/hasSecondaryEmail}}
{{^hasSecondaryEmail}}
<button type="submit" class="settings-button email-add primary disabled">{{#t}}Add{{/t}}</button>
<button class="settings-button cancel secondary enabled">{{#t}}Cancel{{/t}}</button>
{{/hasSecondaryEmail}}
</div>
</form>
</div>
</div>

Просмотреть файл

@ -10,6 +10,7 @@
* 2. Existing users that have signed in with an unverified account.
* 3. Existing users that are signing into Sync and
* must re-confirm their account.
* 4. Existing users that confirmed a secondary email.
*
* The auth server endpoints that are called are the same in all cases.
*/
@ -76,8 +77,10 @@ define(function (require, exports, module) {
const code = verificationInfo.get('code');
const options = {
reminder: verificationInfo.get('reminder'),
secondaryEmailVerified: this.getSearchParam('secondary_email_verified') || null,
serverVerificationStatus: this.getSearchParam('server_verification') || null,
service: this.relier.get('service') || null
service: this.relier.get('service') || null,
type: verificationInfo.get('type')
};
return this.user.completeAccountSignUp(account, code, options)
@ -187,7 +190,9 @@ define(function (require, exports, module) {
* @private
*/
_navigateToVerifiedScreen () {
if (this.isSignUp()) {
if (this.getSearchParam('secondary_email_verified')) {
this.navigate('secondary_email_verified');
} else if (this.isSignUp()) {
this.navigate('signup_verified');
} else {
this.navigate('signin_verified');

Просмотреть файл

@ -18,8 +18,16 @@ define(function (require, exports, module) {
const p = require('lib/promise');
const ProgressIndicator = require('views/progress_indicator');
function showProgressIndicator(handler, _el) {
// Return a promise delayed by ms
function delay(progressIndicator, ms) {
var deferred = p.defer();
progressIndicator.setTimeout(deferred.resolve, ms);
return deferred.promise;
}
function showProgressIndicator(handler, _el, delayMills) {
var el = _el || 'button[type=submit]';
const delayHandlerByMills = delayMills || 0;
return function () {
var args = arguments;
@ -28,7 +36,8 @@ define(function (require, exports, module) {
var progressIndicator = getProgressIndicator(this, target);
progressIndicator.start(target);
return p().then(() => this.invokeHandler(handler, args))
return delay(progressIndicator, delayHandlerByMills)
.then(() => this.invokeHandler(handler, args))
.then(function (value) {
// Stop the progress indicator unless the flow halts.
if (! (value && value.halt)) {

Просмотреть файл

@ -89,12 +89,15 @@ define(function (require, exports, module) {
$('.settings-unit').removeClass('open');
},
displaySuccess (msg) {
displaySuccess (msg, options = {closePanel: true}) {
if (! this.parentView) {
return;
}
this.parentView.displaySuccess(msg);
if (options.closePanel) {
this.closePanel();
}
},
};
});

Просмотреть файл

@ -39,6 +39,10 @@ define(function (require, exports, module) {
headerTitle: t('Password reset'),
readyToSyncText: t('Complete set-up by entering the new password on your other Firefox devices.')
},
SECONDARY_EMAIL_VERIFIED: {
headerId: 'fxa-sign-up-complete-header',
headerTitle: t('Email verified')
},
// signin_confirmed and signin_verified are only shown to Sync for now.
SIGN_IN: {
headerId: 'fxa-sign-in-complete-header',
@ -69,6 +73,7 @@ define(function (require, exports, module) {
isSync: this.relier.isSync(),
readyToSyncText: this._getReadyToSyncText(),
redirectUri: this.relier.get('redirectUri'),
secondaryEmailVerified: this.getSearchParam('secondary_email_verified') || null,
service: this.relier.get('service'),
serviceName: this.relier.get('serviceName')
};

Просмотреть файл

@ -20,6 +20,7 @@ define(function (require, exports, module) {
const ClientsView = require('views/settings/clients');
const ClientDisconnectView = require('views/settings/client_disconnect');
const DisplayNameView = require('views/settings/display_name');
const EmailsView = require('views/settings/emails');
const Duration = require('duration');
const LoadingMixin = require('views/mixins/loading-mixin');
const modal = require('modal'); //eslint-disable-line no-unused-vars
@ -30,6 +31,7 @@ define(function (require, exports, module) {
const Template = require('stache!templates/settings');
var PANEL_VIEWS = [
EmailsView,
AvatarView,
ClientsView,
ClientDisconnectView,

Просмотреть файл

@ -0,0 +1,155 @@
/* 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/. */
define(function (require, exports, module) {
'use strict';
const $ = require('jquery');
const BaseView = require('views/base');
const Cocktail = require('cocktail');
const Email = require('models/email');
const FloatingPlaceholderMixin = require('views/mixins/floating-placeholder-mixin');
const FormView = require('views/form');
const preventDefaultThen = require('views/base').preventDefaultThen;
const SettingsPanelMixin = require('views/mixins/settings-panel-mixin');
const showProgressIndicator = require('views/decorators/progress_indicator');
const Strings = require('lib/strings');
const Template = require('stache!templates/settings/emails');
var t = BaseView.t;
const EMAIL_INPUT_SELECTOR = 'input.new-email';
const EMAIL_REFRESH_SELECTOR = 'button.settings-button.email-refresh';
const EMAIL_REFRESH_DELAYMS = 350;
var View = FormView.extend({
template: Template,
className: 'emails',
viewName: 'settings.emails',
events: {
'click .email-disconnect': preventDefaultThen('_onDisconnectEmail'),
'click .email-refresh.enabled': preventDefaultThen('refresh'),
'click .resend': preventDefaultThen('resend')
},
initialize (options) {
if (options.emails) {
this._emails = options.emails;
} else {
this._emails = [];
}
},
context () {
return {
buttonClass: this._hasSecondaryEmail() ? 'secondary' : 'primary',
emails: this._emails,
hasSecondaryEmail: this._hasSecondaryEmail(),
hasSecondaryVerifiedEmail: this._hasSecondaryVerifiedEmail(),
isPanelOpen: this.isPanelOpen(),
newEmail: this.newEmail
};
},
beforeRender () {
// Only show this view on verified session
return this._isSecondaryEmailEnabled();
},
afterRender () {
// Panel should remain open if there are any unverified secondary emails
if (this._hasSecondaryEmail() && ! this._hasSecondaryVerifiedEmail()) {
this.openPanel();
}
},
_isSecondaryEmailEnabled () {
// Only show secondary email panel if the user is in a verified session and feature is enabled.
const account = this.getSignedInAccount();
return account.sessionVerificationStatus()
.then((res) => {
if (! res.sessionVerified) {
return this.remove();
}
// If we fail to fetch emails, then this user does not have this feature enabled
// and we should not display this panel.
return this._fetchEmails()
.fail(() => {
return this.remove();
});
});
},
_hasSecondaryEmail () {
return this._emails.length > 1;
},
_hasSecondaryVerifiedEmail () {
return this._hasSecondaryEmail() ? this._emails[1].verified : false;
},
_onDisconnectEmail (event) {
const email = $(event.currentTarget).data('id');
const account = this.getSignedInAccount();
return account.recoveryEmailDestroy(email)
.then(()=> {
return this.render()
.then(()=> {
this.navigate('/settings/emails');
});
});
},
_fetchEmails () {
const account = this.getSignedInAccount();
return account.recoveryEmails()
.then((emails) => {
this._emails = emails.map((email) => {
return new Email(email).toJSON();
});
});
},
refresh: showProgressIndicator(function() {
return this.render();
}, EMAIL_REFRESH_SELECTOR, EMAIL_REFRESH_DELAYMS),
resend (event) {
const email = $(event.currentTarget).data('id');
const account = this.getSignedInAccount();
return account.resendEmailCode(email)
.then(() => {
this.displaySuccess(Strings.interpolate(t('A verification link has been sent to %(email)s'), { email: email }), {
closePanel: false
});
this.navigate('/settings/emails');
});
},
submit () {
const newEmail = this.getElementValue('input.new-email');
if (this.isPanelOpen() && newEmail) {
const account = this.getSignedInAccount();
return account.recoveryEmailCreate(newEmail)
.then(() => {
this.displaySuccess(Strings.interpolate(t('Verification emailed to %(email)s'), { email: newEmail }), {
closePanel: false
});
this.render();
})
.fail((err) => this.showValidationError(this.$(EMAIL_INPUT_SELECTOR), err));
}
},
});
Cocktail.mixin(
View,
SettingsPanelMixin,
FloatingPlaceholderMixin
);
module.exports = View;
});

Просмотреть файл

@ -245,6 +245,12 @@ body.settings #main-content.card {
}
}
&.secondary-disabled {
background: $secondary-button-background-color;
border: 1px solid $secondary-button-border-color;
color: $text-color;
}
&.warning {
background: $error-background-color;
border: 0;
@ -641,3 +647,69 @@ section.modal-panel {
display: block;
}
}
.email-list {
padding: 0;
}
.email-address {
height: 40px;
list-style: none;
margin: 10px 0;
position: relative;
html[dir='ltr'] & {
background-position: left 2px;
}
html[dir='rtl'] & {
background-position: right 2px;
}
.address {
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);
& .not-verified {
color: $color-red;
}
& .verified {
color: $color-green;
}
}
.settings-button {
height: 35px;
/*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;
}
html[dir='rtl'] & {
left: 0;
}
}
a.settings-button {
padding-top: 8px;
}
}

Просмотреть файл

@ -0,0 +1,29 @@
/* 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/. */
define(function (require) {
'use strict';
const assert = require('chai').assert;
const Email = require('models/email');
describe('models/email', function () {
let email;
const emailOpts = {
email: 'some@email.com',
isPrimary: false,
verified: false
};
beforeEach(function () {
email = new Email(emailOpts);
});
describe('create', function () {
it('correctly sets model properties', function () {
assert.deepEqual(email.attributes, emailOpts);
});
});
});
});

Просмотреть файл

@ -200,7 +200,7 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: null, serverVerificationStatus: null, service: validService});
assert.deepEqual(args[1], {reminder: null, secondaryEmailVerified: null, serverVerificationStatus: null, service: validService, type: null});
});
});
@ -219,7 +219,7 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: validReminder, serverVerificationStatus: null, service: null});
assert.deepEqual(args[1], {reminder: validReminder, secondaryEmailVerified: null, serverVerificationStatus: null, service: null, type: null});
});
});
@ -239,7 +239,7 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: validReminder, serverVerificationStatus: null, service: validService});
assert.deepEqual(args[1], {reminder: validReminder, secondaryEmailVerified: null, serverVerificationStatus: null, service: validService, type: null});
});
});
@ -259,7 +259,48 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: null, serverVerificationStatus: 'verified', service: null});
assert.deepEqual(args[1], {reminder: null, secondaryEmailVerified: null, serverVerificationStatus: 'verified', service: null, type: null});
});
});
describe('if type is in the url', function () {
beforeEach(function () {
windowMock.location.search = '?code=' + validCode + '&uid=' + validUid +
'&type=secondary';
relier = new Relier({}, {
window: windowMock
});
relier.fetch();
initView(account);
return view.render();
});
it('attempt to pass type to verifySignUp', function () {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: null, secondaryEmailVerified: null, serverVerificationStatus: null, service: null, type: 'secondary'});
});
});
describe('if secondary_email_verified is in the url', function () {
beforeEach(function () {
windowMock.location.search = '?code=' + validCode + '&uid=' + validUid +
'&secondary_email_verified=some@email.com';
relier = new Relier({}, {
window: windowMock
});
relier.fetch();
initView(account);
return view.render();
});
it('attempt to pass secondary_email_verified to verifySignUp', function () {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: null, secondaryEmailVerified: 'some@email.com', serverVerificationStatus: null, service: null, type: null});
});
});

Просмотреть файл

@ -0,0 +1,323 @@
/* 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/. */
define(function (require, exports, module) {
'use strict';
const $ = require('jquery');
const assert = require('chai').assert;
const BaseBroker = require('models/auth_brokers/base');
const BaseView = require('views/base');
const Metrics = require('lib/metrics');
const Notifier = require('lib/channels/notifier');
const p = require('lib/promise');
const sinon = require('sinon');
const TestHelpers = require('../../../lib/helpers');
const Translator = require('lib/translator');
const User = require('models/user');
const View = require('views/settings/emails');
const WindowMock = require('../../../mocks/window');
describe('views/settings/emails', function () {
let account;
let emails;
let broker;
let email;
let metrics;
let notifier;
let parentView;
let translator;
const UID = '123';
let user;
let view;
let windowMock;
function initView() {
view = new View({
broker: broker,
emails: emails,
metrics: metrics,
notifier: notifier,
parentView: parentView,
translator: translator,
user: user,
window: windowMock
});
return view.render();
}
beforeEach(() => {
broker = new BaseBroker();
email = TestHelpers.createEmail();
notifier = new Notifier();
metrics = new Metrics({notifier});
parentView = new BaseView();
translator = new Translator({forceEnglish: true});
user = new User();
windowMock = new WindowMock();
account = user.initAccount({
email: email,
sessionToken: 'abc123',
uid: UID,
verified: true
});
emails = [];
sinon.stub(user, 'getSignedInAccount', () => {
return account;
});
});
afterEach(() => {
if ($.prototype.trigger.restore) {
$.prototype.trigger.restore();
}
view.remove();
view.destroy();
view = null;
});
describe('constructor', () => {
beforeEach(() => {
view = new View({
notifier: notifier,
parentView: parentView,
user: user
});
});
it('creates `Email` instances if passed in', () => {
assert.ok(view._emails);
});
});
describe('feature disabled', () => {
describe('for user', () => {
beforeEach(() => {
sinon.stub(account, 'recoveryEmails', () => {
return p.reject();
});
sinon.stub(account, 'sessionVerificationStatus', () => {
return p({sessionVerified: true});
});
view = new View({
broker: broker,
emails: emails,
metrics: metrics,
notifier: notifier,
parentView: parentView,
translator: translator,
user: user,
window: windowMock
});
sinon.stub(view, 'remove', () => {
return true;
});
return view.render();
});
it('should be disabled when feature is disabled for user', () => {
assert.equal(view.remove.callCount, 1);
});
});
describe('for unverified session', () => {
beforeEach(() => {
sinon.stub(account, 'recoveryEmails', () => {
return p();
});
sinon.stub(account, 'sessionVerificationStatus', () => {
return p({sessionVerified: false});
});
view = new View({
broker: broker,
emails: emails,
metrics: metrics,
notifier: notifier,
parentView: parentView,
translator: translator,
user: user,
window: windowMock
});
sinon.stub(view, 'remove', () => {
return true;
});
return view.render();
});
it('should be disabled when in unverified session', () => {
assert.equal(view.remove.callCount, 1);
});
});
});
describe('feature enabled', () => {
beforeEach(() => {
sinon.stub(account, 'recoveryEmails', () => {
return p(emails);
});
sinon.stub(account, 'sessionVerificationStatus', () => {
return p({sessionVerified: true});
});
sinon.stub(account, 'recoveryEmailDestroy', () => {
return p();
});
sinon.stub(account, 'resendEmailCode', () => {
return p();
});
});
describe('with no secondary email', () => {
beforeEach(() => {
emails = [{
email: 'primary@email.com',
isPrimary: true,
verified: true
}];
return initView();
});
it('has email input field', function () {
assert.ok(view.$('input.new-email').length, 1);
assert.ok(view.$('.email-add.primary.disabled').length, 1);
});
it('add button enabled when email entered', function () {
view.$('input.new-email').val('asdf@email.com');
view.$('input.new-email').trigger({
type: 'keyup',
which: 9
});
assert.ok(view.$('.email-add.primary:not(.disabled)').length, 1);
});
});
describe('with unverified secondary email', () => {
beforeEach(() => {
emails = [{
email: 'primary@email.com',
isPrimary: true,
verified: true
}, {
email: 'another@one.com',
isPrimary: false,
verified: false
}];
return initView()
.then(function () {
// click events require the view to be in the DOM
$('#container').html(view.el);
sinon.spy(view, 'navigate');
});
});
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.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');
});
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();
}, 150);
});
it('calls `render` when refreshed', (done) => {
$('.email-refresh').click();
sinon.spy(view, 'render');
setTimeout(function () {
assert.isTrue(view.render.calledOnce);
done();
}, 450); // Delay is higher here because refresh has a min delay of 350
});
it('calls `render` when resend and navigate to /emails', (done) => {
$('.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();
}, 150);
});
it('panel always open when unverified secondary email', () => {
assert.equal(view.isPanelOpen(), true);
});
});
describe('with verified secondary email', () => {
beforeEach(() => {
emails = [{
email: 'primary@email.com',
isPrimary: true,
verified: true
}, {
email: 'another@one.com',
isPrimary: false,
verified: true
}];
return initView()
.then(function () {
// click events require the view to be in the DOM
$('#container').html(view.el);
sinon.spy(view, 'navigate');
});
});
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.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');
});
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();
}, 150);
});
it('panel closed when verified secondary email', () => {
assert.equal(view.isPanelOpen(), false);
});
});
});
});
});

Просмотреть файл

@ -81,6 +81,7 @@ function (Translator, Session) {
'../tests/spec/models/auth_brokers/oauth-redirect',
'../tests/spec/models/auth_brokers/web',
'../tests/spec/models/device',
'../tests/spec/models/email',
'../tests/spec/models/email-resend',
'../tests/spec/models/flow',
'../tests/spec/models/form-prefill',
@ -176,6 +177,7 @@ function (Translator, Session) {
'../tests/spec/views/settings/communication_preferences',
'../tests/spec/views/settings/delete_account',
'../tests/spec/views/settings/display_name',
'../tests/spec/views/settings/emails',
'../tests/spec/views/sign_in',
'../tests/spec/views/sign_in_reported',
'../tests/spec/views/sign_in_unblock',

Просмотреть файл

@ -37,6 +37,7 @@ module.exports = function () {
'settings/communication_preferences',
'settings/delete_account',
'settings/display_name',
'settings/emails',
'signin',
'signin_confirmed',
'signin_permissions',
@ -47,10 +48,12 @@ module.exports = function () {
'signup_confirmed',
'signup_permissions',
'signup_verified',
'secondary_email_verified',
'sms',
'sms/sent',
'sms/why',
'verify_email'
'verify_email',
'verify_secondary_email'
].join('|'); // prepare for use in a RegExp
return {

Просмотреть файл

@ -47,6 +47,7 @@ define([
'./functional/settings',
'./functional/settings_clients',
'./functional/settings_common',
'./functional/settings_secondary_emails.js',
'./functional/sync_settings',
'./functional/change_password',
'./functional/force_auth',

Просмотреть файл

@ -0,0 +1,149 @@
/* 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'
], function (intern, registerSuite, TestHelpers, FunctionalHelpers) {
const config = intern.config;
const SIGNUP_URL = config.fxaContentRoot + 'signup';
const PASSWORD = 'password';
let email;
let secondaryEmail;
const clearBrowserState = FunctionalHelpers.clearBrowserState;
const click = FunctionalHelpers.click;
const createUser = FunctionalHelpers.createUser;
const fillOutResetPassword = FunctionalHelpers.fillOutResetPassword;
const fillOutSignIn = FunctionalHelpers.fillOutSignIn;
const fillOutSignUp = FunctionalHelpers.fillOutSignUp;
const openPage = FunctionalHelpers.openPage;
const openVerificationLinkInSameTab = FunctionalHelpers.openVerificationLinkInSameTab;
const testElementExists = FunctionalHelpers.testElementExists;
const testElementTextEquals = FunctionalHelpers.testElementTextEquals;
const testErrorTextInclude = FunctionalHelpers.testErrorTextInclude;
const type = FunctionalHelpers.type;
const visibleByQSA = FunctionalHelpers.visibleByQSA;
registerSuite({
name: 'settings secondary emails',
beforeEach: function () {
email = TestHelpers.createEmail();
secondaryEmail = TestHelpers.createEmail();
return this.remote.then(clearBrowserState());
},
afterEach: function () {
return this.remote.then(clearBrowserState());
},
'add and verify secondary email': function () {
return this.remote
// sign up via the UI, we need a verified session to use secondary email
.then(openPage(SIGNUP_URL, '#fxa-signup-header'))
.then(fillOutSignUp(email, PASSWORD))
.then(testElementExists('#fxa-confirm-header'))
.then(openVerificationLinkInSameTab(email, 0))
.then(testElementExists('#fxa-settings-header'))
.then(click('#emails .settings-unit-stub button'))
// attempt to the same email as primary
.then(type('.new-email', email))
.then(click('.email-add:not(.disabled)'))
.then(visibleByQSA('.tooltip'))
// add secondary email, resend and remove
.then(type('.new-email', TestHelpers.createEmail()))
.then(click('.email-add:not(.disabled)'))
.then(testElementExists('.not-verified'))
.then(click('.email-disconnect'))
// add secondary email, verify
.then(type('.new-email', secondaryEmail))
.then(click('.email-add:not(.disabled)'))
.then(testElementExists('.not-verified'))
.then(openVerificationLinkInSameTab(secondaryEmail, 0))
.then(click('#emails .settings-unit-stub button'))
.then(testElementTextEquals('#emails .address', secondaryEmail))
.then(testElementExists('.verified'))
// sign out, try to sign in with secondary
.then(click('#signout'))
.then(testElementExists('#fxa-signin-header'))
.then(fillOutSignIn(secondaryEmail, PASSWORD))
.then(testErrorTextInclude('primary account email required'))
// try to reset with secondary email
.then(fillOutResetPassword(secondaryEmail, PASSWORD))
.then(testErrorTextInclude('primary account email required'))
// make sure sign in still works
.then(fillOutSignIn(email, PASSWORD));
},
'add secondary email that is primary to another account': function () {
const existingUnverified = TestHelpers.createEmail();
const existingVerified = TestHelpers.createEmail();
const unverifiedAccountEmail = TestHelpers.createEmail();
return this.remote
// create an unverified and verified accounts
// these are going to be tried as a secondary emails for another account
.then(createUser(existingUnverified, PASSWORD, { preVerified: false }))
.then(createUser(existingVerified, PASSWORD, { preVerified: true }))
.then(openPage(SIGNUP_URL, '#fxa-signup-header'))
.then(fillOutSignUp(unverifiedAccountEmail, PASSWORD))
.then(testElementExists('#fxa-confirm-header'))
// sign up and verify
.then(openPage(SIGNUP_URL, '#fxa-signup-header'))
.then(fillOutSignUp(email, PASSWORD))
.then(testElementExists('#fxa-confirm-header'))
.then(openVerificationLinkInSameTab(email, 0))
.then(click('#emails .settings-unit-stub button'))
.then(type('.new-email', unverifiedAccountEmail))
.then(click('.email-add:not(.disabled)'))
.then(visibleByQSA('.tooltip'));
},
'signin and signup with existing secondary email': function () {
return this.remote
// sign up via the UI, we need a verified session to use secondary email
.then(openPage(SIGNUP_URL, '#fxa-signup-header'))
.then(fillOutSignUp(email, PASSWORD))
.then(testElementExists('#fxa-confirm-header'))
.then(openVerificationLinkInSameTab(email, 0))
.then(testElementExists('#fxa-settings-header'))
.then(click('#emails .settings-unit-stub button'))
.then(type('.new-email', secondaryEmail))
.then(click('.email-add:not(.disabled)'))
.then(testElementExists('.not-verified'))
.then(openVerificationLinkInSameTab(secondaryEmail, 0))
.then(click('#emails .settings-unit-stub button'))
.then(testElementExists('.verified'))
.then(click('#signout'))
.then(testElementExists('#fxa-signin-header'))
// try to signin with the secondary email
.then(fillOutSignIn(secondaryEmail, PASSWORD))
.then(testErrorTextInclude('Primary account email required'))
// try to signup with the secondary email
.then(openPage(SIGNUP_URL, '#fxa-signup-header'))
.then(fillOutSignUp(email, PASSWORD))
.then(testElementExists('#fxa-settings-content'));
}
});
});

Просмотреть файл

@ -8,6 +8,7 @@ define([
], function (intern, selectCircleTests) {
intern.functionalSuites = selectCircleTests([
'tests/functional/settings_secondary_emails.js',
// flaky tests go above here.
'tests/functional/avatar',
'tests/functional/back_button_after_start',