feat(devices): basic device UI for the settings panel

This commit is contained in:
Shane Tomlinson 2015-10-27 17:50:57 +00:00 коммит произвёл Vlad Filippov
Родитель 3fa1ca8658
Коммит 20c305d52c
30 изменённых файлов: 1129 добавлений и 13 удалений

Двоичные данные
app/images/device-icon-mobile.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 369 B

Двоичные данные
app/images/device-icon-pc.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 484 B

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

@ -20,7 +20,7 @@ define(function (require, exports, module) {
DELETE: 'fxaccounts:delete',
PROFILE_CHANGE: 'profile:change',
SIGNED_IN: 'internal:signed_in',
SIGNED_OUT: 'internal:signed_out'
SIGNED_OUT: 'fxaccounts:logout'
};
var Notifer = Backbone.Model.extend({

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

@ -470,6 +470,20 @@ define(function (require, exports, module) {
.then(function (client) {
return client.accountKeys(keyFetchToken, unwrapBKey);
});
},
deviceList: function (sessionToken) {
return this._getClient()
.then(function (client) {
return client.deviceList(sessionToken);
});
},
deviceDestroy: function (sessionToken, deviceId) {
return this._getClient()
.then(function (client) {
return client.deviceDestroy(sessionToken, deviceId);
});
}
};

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

@ -24,6 +24,7 @@ define(function (require, exports, module) {
var ConfirmView = require('../views/confirm');
var CookiesDisabledView = require('../views/cookies_disabled');
var DeleteAccountView = require('../views/settings/delete_account');
var DevicesView = require('../views/settings/devices');
var DisplayNameView = require('../views/settings/display_name');
var ForceAuthView = require('../views/force_auth');
var GravatarPermissionsView = require('../views/settings/gravatar_permissions');
@ -88,6 +89,7 @@ define(function (require, exports, module) {
'settings/change_password(/)': createChildViewHandler(ChangePasswordView, SettingsView),
'settings/communication_preferences(/)': createChildViewHandler(CommunicationPreferencesView, SettingsView),
'settings/delete_account(/)': createChildViewHandler(DeleteAccountView, SettingsView),
'settings/devices(/)': createChildViewHandler(DevicesView, SettingsView),
'settings/display_name(/)': createChildViewHandler(DisplayNameView, SettingsView),
'signin(/)': createViewHandler(SignInView),
'signin_complete(/)': createViewHandler(ReadyView, { type: 'sign_in' }),

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

@ -328,6 +328,10 @@ define(function (require, exports, module) {
});
},
signOut: function () {
return this._fxaClient.signOut(this.get('sessionToken'));
},
saveGrantedPermissions: function (clientId, clientPermissions) {
var permissions = this.get('grantedPermissions') || {};
permissions[clientId] = clientPermissions;
@ -415,6 +419,38 @@ define(function (require, exports, module) {
.then(function (updatedSessionData) {
self.set(updatedSessionData);
});
},
/**
* Fetch the account's device list and populate the `devices` collection.
*
* @param {object} devices - Devices collection
* @returns {promise} - resolves when complete
*/
fetchDevices: function (devices) {
var sessionToken = this.get('sessionToken');
return this._fxaClient.deviceList(sessionToken)
.then(devices.set.bind(devices));
},
/**
* Delete the device from the account
*
* @param {object} device - Device model to remove
* @returns {promise} - resolves when complete
*
* @param {object} devices - Devices collection
* @returns {promise} - resolves when complete
*/
destroyDevice: function (device) {
var deviceId = device.get('id');
var sessionToken = this.get('sessionToken');
return this._fxaClient.deviceDestroy(sessionToken, deviceId)
.then(function () {
device.destroy();
});
}
});

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

@ -0,0 +1,32 @@
/* 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/. */
/**
* Device information
*/
define(function (require, exports, module) {
'use strict';
var Backbone = require('backbone');
var Device = Backbone.Model.extend({
defaults: {
id: null,
isCurrentDevice: null,
lastConnected: null,
name: null,
type: null
},
destroy: function () {
// Both a sessionToken and deviceId are needed to destroy a device.
// An account `has a` device, therefore account destroys the device.
this.trigger('destroy', this);
}
});
module.exports = Device;
});

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

@ -0,0 +1,46 @@
/* 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/. */
/**
* A collection of devices
*/
define(function (require, exports, module) {
'use strict';
var Backbone = require('backbone');
var Device = require('models/device');
var Devices = Backbone.Collection.extend({
model: Device,
initialize: function (models, options) {
options = options || {};
},
comparator: function (a, b) {
// 1. the current device is first.
// 2. the rest sorted in alphabetical order.
if (a.get('isCurrentDevice')) {
return -1;
}
var aName = (a.get('name') || '').trim().toLowerCase();
var bName = (b.get('name') || '').trim().toLowerCase();
if (aName < bName) {
return -1;
} else if (a === b) {
return 0;
}
return 1;
}
});
module.exports = Devices;
});

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

@ -115,6 +115,10 @@ define(function (require, exports, module) {
return this._cachedSignedInAccount;
},
isSignedInAccount: function (account) {
return account.get('uid') === this.getSignedInAccount().get('uid');
},
setSignedInAccountByUid: function (uid) {
if (this._accounts()[uid]) {
this._setSignedInAccountUid(uid);
@ -153,8 +157,11 @@ define(function (require, exports, module) {
// Used to clear the current account, but keeps the account details
clearSignedInAccount: function () {
var uid = this.getSignedInAccount().get('uid');
this.clearSignedInAccountUid();
this._notifier.triggerRemote(this._notifier.EVENTS.SIGNED_OUT);
this._notifier.triggerRemote(this._notifier.EVENTS.SIGNED_OUT, {
uid: uid
});
},
removeAllAccounts: function () {
@ -165,12 +172,13 @@ define(function (require, exports, module) {
// Delete the account from storage
removeAccount: function (accountData) {
var account = this.initAccount(accountData);
var uid = account.get('uid');
var accounts = this._accounts();
if (uid === this.getSignedInAccount().get('uid')) {
if (this.isSignedInAccount(account)) {
this.clearSignedInAccount();
}
var accounts = this._accounts();
var uid = account.get('uid');
delete accounts[uid];
this._storage.set('accounts', accounts);
},
@ -291,6 +299,19 @@ define(function (require, exports, module) {
});
},
signOutAccount: function (account) {
var self = this;
return account.signOut()
.fin(function () {
// Clear the session, even on failure. Everything is A-OK.
// See issue #616
if (self.isSignedInAccount(account)) {
self.clearSignedInAccount();
}
});
},
changeAccountPassword: function (account, oldPassword, newPassword, relier) {
var self = this;
return account.changePassword(oldPassword, newPassword, relier)
@ -305,6 +326,36 @@ define(function (require, exports, module) {
.then(function () {
return self.setSignedInAccount(account);
});
},
/**
* Fetch the devices for the given account, populated the passed in
* Devices collection.
*
* @param {object} account - account for which device list is requested
* @param {object} devices - Devices collection used to store list.
* @returns {promise} resolves when the action completes
*/
fetchAccountDevices: function (account, devices) {
return account.fetchDevices(devices);
},
/**
* Destroy a device on the given account. If the current device
* is destroyed, sign out the user.
*
* @param {object} account - account with the device
* @param {object} device - device to destroy
* @returns {promise} resolves when the action completes
*/
destroyAccountDevice: function (account, device) {
var self = this;
return account.destroyDevice(device)
.then(function () {
if (self.isSignedInAccount(account) && device.get('isCurrentDevice')) {
self.clearSignedInAccount();
}
});
}
});

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

@ -0,0 +1,41 @@
{{#isPanelEnabled}}
<div id="devices" class="settings-unit {{#isPanelOpen}}open{{/isPanelOpen}}">
<div class="settings-unit-stub">
<header class="settings-unit-summary">
<h2 class="settings-unit-title">{{#t}}Devices{{/t}}</h2>
</header>
<button class="settings-button secondary settings-unit-toggle hidden"
data-href="settings/devices">
{{#t}}Show{{/t}}
</button>
</div>
<div class="settings-unit-details">
<div class="error"></div>
<form novalidate>
{{#t}}Firefox is available for <a href="%(linkWindows)s">Windows</a>, <a href="%(linkOSX)s">OS X</a>, <a href="%(linkAndroid)s">Android</a>, <a href="%(linkIOS)s">iOS</a> and <a href="%(linkLinux)s">Linux</a>.{{/t}}
{{#t}}You can manage your devices below.{{/t}}
<ul class="device-list button-row">
{{#devices}}
<li class="device {{type}}" id="{{id}}">
<div class="device-name">
{{name}} {{#isCurrentDevice}}<span class="device-current">{{#t}}(current){{/t}}</span>{{/isCurrentDevice}}
</div>
<div class="last-connected">
{{lastConnected}}
</div>
<button class="settings-button warning device-disconnect" data-id="{{id}}">{{#t}}Disconnect{{/t}}</button>
</li>
{{/devices}}
<a href="{{devicesSupportUrl}}">{{#t}}Don't see all your devices?{{/t}}</a>
</ul>
<div class="button-row">
<button type="submit" class="settings-button primary devices-refresh">{{#t}}Refresh{{/t}}</button>
<button class="settings-button secondary cancel">{{#t}}Done{{/t}}</button>
</div>
</form>
</div>
</div>
{{/isPanelEnabled}}

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

@ -198,6 +198,7 @@ define(function (require, exports, module) {
.then(_.bind(self.afterRender, self))
.then(function () {
self.showEphemeralMessages();
self.trigger('rendered');
return true;
});

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

@ -29,6 +29,10 @@ define(function (require, exports, module) {
// to ensure their data isn't sticking around in memory.
this._formPrefill.clear();
Session.clear();
this.navigateToSignIn();
},
navigateToSignIn: function () {
this.navigate('signin', {
clearQueryParams: true,
success: BaseView.t('Signed out successfully')

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

@ -17,6 +17,7 @@ define(function (require, exports, module) {
var Cocktail = require('cocktail');
var CommunicationPreferencesView = require('views/settings/communication_preferences');
var DeleteAccountView = require('views/settings/delete_account');
var DevicesView = require('views/settings/devices');
var DisplayNameView = require('views/settings/display_name');
var GravatarPermissionsView = require('views/settings/gravatar_permissions');
var GravatarView = require('views/settings/avatar_gravatar');
@ -30,6 +31,7 @@ define(function (require, exports, module) {
var PANEL_VIEWS = [
AvatarView,
DisplayNameView,
DevicesView,
CommunicationPreferencesView,
ChangePasswordView,
DeleteAccountView,
@ -187,19 +189,16 @@ define(function (require, exports, module) {
signOut: allowOnlyOneSubmit(function () {
var self = this;
var sessionToken = self.getSignedInAccount().get('sessionToken');
var accountToSignOut = self.getSignedInAccount();
self.logViewEvent('signout.submit');
return self.fxaClient.signOut(sessionToken)
return self.user.signOutAccount(accountToSignOut)
.fail(function () {
// ignore the error.
// Clear the session, even on failure. Everything is A-OK.
// See issue #616
// log and ignore the error.
self.logViewEvent('signout.error');
})
.fin(function () {
self.logViewEvent('signout.success');
self.user.clearSignedInAccount();
self.clearSessionAndNavigateToSignIn();
});
}),

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

@ -0,0 +1,137 @@
/* 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';
var $ = require('jquery');
var Cocktail = require('cocktail');
var Devices = require('models/devices');
var FormView = require('views/form');
var Notifier = require('lib/channels/notifier');
var preventDefaultThen = require('views/base').preventDefaultThen;
var SettingsPanelMixin = require('views/mixins/settings-panel-mixin');
var SignedOutNotificationMixin = require('views/mixins/signed-out-notification-mixin');
var Template = require('stache!templates/settings/devices');
var Url = require('lib/url');
var DEVICE_REMOVED_ANIMATION_MS = 150;
var DEVICES_SUPPORT_URL = 'https://support.mozilla.org/kb/fxa-managing-devices';
var FIREFOX_DOWNLOAD_LINK = 'https://www.mozilla.org/firefox/new/?utm_source=accounts.firefox.com&utm_medium=referral&utm_campaign=fxa-devices';
var FORCE_DEVICE_LIST_VIEW = 'forceDeviceList';
var View = FormView.extend({
template: Template,
className: 'devices',
viewName: 'settings.devices',
initialize: function (options) {
this._able = options.able;
this._devices = options.devices;
// An empty Devices instance is created to render the initial view.
// Data is only fetched once the panel has been opened.
if (! this._devices) {
this._devices = new Devices([], {
notifier: options.notifier
});
}
var devices = this._devices;
devices.on('add', this._onDeviceAdded.bind(this));
devices.on('remove', this._onDeviceRemoved.bind(this));
var notifier = options.notifier;
notifier.on(Notifier.DEVICE_REFRESH, this._onRefreshDeviceList.bind(this));
},
context: function () {
return {
devices: this._devices.toJSON(),
devicesSupportUrl: DEVICES_SUPPORT_URL,
isPanelEnabled: this._isPanelEnabled(),
isPanelOpen: this.isPanelOpen(),
linkAndroid: FIREFOX_DOWNLOAD_LINK,
linkIOS: FIREFOX_DOWNLOAD_LINK,
linkLinux: FIREFOX_DOWNLOAD_LINK,
linkOSX: FIREFOX_DOWNLOAD_LINK,
linkWindows: FIREFOX_DOWNLOAD_LINK
};
},
events: {
'click .device-disconnect': preventDefaultThen('_onDisconnectDevice'),
'click .devices-refresh': preventDefaultThen('_onRefreshDeviceList')
},
_isPanelEnabled: function () {
return this._able.choose('deviceListVisible', {
forceDeviceList: Url.searchParam(FORCE_DEVICE_LIST_VIEW, this.window.location.search)
});
},
_onDeviceAdded: function () {
this.render();
},
_onDeviceRemoved: function (device) {
var id = device.get('id');
var self = this;
$('#' + id).slideUp(DEVICE_REMOVED_ANIMATION_MS, function () {
// re-render in case the last device is removed and the
// "no registered devices" message needs to be shown.
self.render();
});
},
_onDisconnectDevice: function (event) {
this.logViewEvent('disconnect');
var deviceId = $(event.currentTarget).attr('data-id');
this._destroyDevice(deviceId);
},
_onRefreshDeviceList: function () {
if (this.isPanelOpen()) {
this.logViewEvent('refresh');
// only refresh devices if panel is visible
// if panel is hidden there is no point of fetching devices
this._fetchDevices();
}
},
openPanel: function () {
this.logViewEvent('open');
this._fetchDevices();
},
_fetchDevices: function () {
var account = this.getSignedInAccount();
return this.user.fetchAccountDevices(account, this._devices);
},
_destroyDevice: function (deviceId) {
var self = this;
var account = this.getSignedInAccount();
var device = this._devices.get(deviceId);
if (device) {
this.user.destroyAccountDevice(account, device)
.then(function () {
if (device.get('isCurrentDevice')) {
self.navigateToSignIn();
}
});
}
}
});
Cocktail.mixin(
View,
SettingsPanelMixin,
SignedOutNotificationMixin
);
module.exports = View;
});

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

@ -385,3 +385,43 @@ body.settings #main-content.card {
width: 320px;
}
}
.device-list {
padding: 0;
}
.device {
background-position: 0 4px;
background-repeat: no-repeat;
background-size: 30px 30px;
height: 40px;
list-style: none;
margin: 10px 0;
padding-left: 40px;
position: relative;
.settings-button {
position: absolute;
top: 0;
right: 0;
width: 20%;
height: 35px;
}
.device-name {
}
.last-connected {
font-size: 12px;
color: #909ca8;
}
}
.device.desktop {
background-image: url(/images/device-icon-pc.png);
}
.device.mobile {
background-image: url(/images/device-icon-mobile.png);
}

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

@ -854,6 +854,33 @@ define(function (require, exports, module) {
});
});
});
describe('deviceList', function () {
beforeEach(function () {
sinon.stub(realClient, 'deviceList', function () {
return p();
});
return client.deviceList('session token');
});
it('calls `deviceList` of the realClient', function () {
assert.isTrue(realClient.deviceList.calledWith('session token'));
});
});
describe('deviceDestroy', function () {
beforeEach(function () {
sinon.stub(realClient, 'deviceDestroy', function () {
return p();
});
return client.deviceDestroy('session token', 'device id');
});
it('calls `deviceDestroy` of the realClient', function () {
assert.isTrue(
realClient.deviceDestroy.calledWith('session token', 'device id'));
});
});
});
});

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

@ -10,6 +10,8 @@ define(function (require, exports, module) {
var AuthErrors = require('lib/auth-errors');
var chai = require('chai');
var Constants = require('lib/constants');
var Device = require('models/device');
var Devices = require('models/devices');
var FxaClientWrapper = require('lib/fxa-client');
var MarketingEmailClient = require('lib/marketing-email-client');
var OAuthClient = require('lib/oauth-client');
@ -976,5 +978,69 @@ define(function (require, exports, module) {
});
});
});
describe('fetchDevices', function () {
var devices;
beforeEach(function () {
account.set('sessionToken', SESSION_TOKEN);
devices = new Devices([], {
notifier: {
on: sinon.spy()
}
});
sinon.stub(fxaClient, 'deviceList', function () {
return p([
{
id: 'device-1',
isCurrentDevice: false,
name: 'alpha'
},
{
id: 'device-2',
isCurrentDevice: true,
name: 'beta'
}
]);
});
return account.fetchDevices(devices);
});
it('fetches the device list from the back end', function () {
assert.isTrue(fxaClient.deviceList.calledWith(SESSION_TOKEN));
});
it('populates the `devices` collection', function () {
assert.equal(devices.length, 2);
});
});
describe('destroyDevice', function () {
var device;
beforeEach(function () {
account.set('sessionToken', SESSION_TOKEN);
device = new Device({
id: 'device-1',
isCurrentDevice: true,
name: 'alpha'
});
sinon.stub(fxaClient, 'deviceDestroy', function () {
return p();
});
return account.destroyDevice(device);
});
it('tells the backend to destroy the device', function () {
assert.isTrue(
fxaClient.deviceDestroy.calledWith(SESSION_TOKEN, 'device-1'));
});
});
});
});

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

@ -0,0 +1,32 @@
/* 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';
var assert = require('chai').assert;
var Device = require('models/device');
var sinon = require('sinon');
describe('models/device', function () {
var device;
beforeEach(function () {
device = new Device();
});
describe('destroy', function () {
beforeEach(function () {
sinon.spy(device, 'trigger');
device.destroy();
});
it('triggers a `destroy` message', function () {
assert.isTrue(device.trigger.calledWith('destroy'));
});
});
});
});

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

@ -0,0 +1,85 @@
/* 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';
var assert = require('chai').assert;
var Devices = require('models/devices');
var Notifier = require('lib/channels/notifier');
describe('models/devices', function () {
var devices;
var notifier;
beforeEach(function () {
notifier = new Notifier();
devices = new Devices([], {
notifier: notifier
});
});
describe('device list ordering', function () {
beforeEach(function () {
devices.set([
{
isCurrentDevice: false,
name: 'zeta'
},
{
isCurrentDevice: true,
name: 'upsilon'
},
{
isCurrentDevice: false,
name: 'xi'
},
{
isCurrentDevice: false,
name: 'tau'
},
{
isCurrentDevice: false,
name: 'tau'
},
{
isCurrentDevice: false,
name: 'theta'
}
]);
});
it('places the `current` device first', function () {
assert.equal(devices.at(0).get('name'), 'upsilon');
});
it('sorts the rest alphabetically', function () {
assert.equal(devices.at(1).get('name'), 'tau');
assert.equal(devices.at(2).get('name'), 'tau');
assert.equal(devices.at(3).get('name'), 'theta');
assert.equal(devices.at(4).get('name'), 'xi');
assert.equal(devices.at(5).get('name'), 'zeta');
});
});
describe('device name change', function () {
beforeEach(function () {
devices.set([
{
id: 'device-1',
isCurrentDevice: false,
name: 'zeta'
},
{
id: 'device-2',
isCurrentDevice: true,
name: 'upsilon'
}
]);
});
});
});
});

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

@ -8,6 +8,8 @@ define(function (require, exports, module) {
var AuthErrors = require('lib/auth-errors');
var chai = require('chai');
var Constants = require('lib/constants');
var Device = require('models/device');
var Devices = require('models/devices');
var FxaClient = require('lib/fxa-client');
var Notifier = require('lib/channels/notifier');
var p = require('lib/promise');
@ -117,7 +119,7 @@ define(function (require, exports, module) {
assert.equal(user.getAccountByUid('uid').get('uid'), 'uid');
assert.equal(notifier.triggerRemote.callCount, 1);
var args = notifier.triggerRemote.args[0];
assert.lengthOf(args, 1);
assert.lengthOf(args, 2);
assert.equal(args[0], notifier.EVENTS.SIGNED_OUT);
});
});
@ -522,5 +524,87 @@ define(function (require, exports, module) {
assert.equal(resumeTokenInfo.uniqueUserId, UUID);
});
});
describe('fetchAccountDevices', function () {
var account;
var devices;
beforeEach(function () {
account = user.initAccount({});
sinon.stub(account, 'fetchDevices', function () {
return p();
});
devices = new Devices([], {
notifier: {
on: sinon.spy()
}
});
return user.fetchAccountDevices(account, devices);
});
it('delegates to the account to fetch devices', function () {
assert.isTrue(account.fetchDevices.calledWith(devices));
});
});
describe('destroyAccountDevice', function () {
var account;
var device;
beforeEach(function () {
account = user.initAccount({
email: 'a@a.com',
sessionToken: 'session token',
uid: 'the uid'
});
sinon.stub(account, 'destroyDevice', function () {
return p();
});
sinon.stub(account, 'fetch', function () {
return p();
});
sinon.spy(user, 'clearSignedInAccount');
device = new Device({
id: 'device-1',
name: 'alpha',
sessionToken: 'session token'
});
return user.destroyAccountDevice(account, device);
});
it('delegates to the account to destroy the device', function () {
assert.isTrue(account.destroyDevice.calledWith(device));
});
describe('with a remote device', function () {
it('does not sign out the current user', function () {
assert.isFalse(user.clearSignedInAccount.called);
});
});
describe('with the current account\'s current device', function () {
beforeEach(function () {
device.set('isCurrentDevice', true);
return user.setSignedInAccount(account)
.then(function () {
return user.destroyAccountDevice(account, device);
});
});
it('signs out the current account', function () {
assert.isTrue(user.clearSignedInAccount.called);
});
});
});
});
});

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

@ -96,6 +96,15 @@ define(function (require, exports, module) {
assert.isTrue($('body').hasClass('layout'));
});
it('triggers the `rendered` message when complete', function () {
var deferred = p.defer();
view.on('rendered', deferred.resolve.bind(deferred));
view.render();
return deferred.promise;
});
it('updates the page title with the embedded h1 and h2 tags', function () {
view.template = function () {
return '<header><h1>Main title</h1><h2>Sub title</h2></header>';

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

@ -19,7 +19,7 @@ define(function (require, exports, module) {
describe('views/mixins/signed-out-notification-mixin', function () {
it('exports correct interface', function () {
assert.lengthOf(Object.keys(SignedOutNotificationMixin), 2);
assert.lengthOf(Object.keys(SignedOutNotificationMixin), 3);
assert.isObject(SignedOutNotificationMixin.notifications);
assert.isFunction(SignedOutNotificationMixin.clearSessionAndNavigateToSignIn);
});

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

@ -0,0 +1,248 @@
/* 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';
var $ = require('jquery');
var _ = require ('underscore');
var Able = require('lib/able');
var assert = require('chai').assert;
var BaseView = require('views/base');
var Devices = require('models/devices');
var Notifier = require('lib/channels/notifier');
var p = require('lib/promise');
var sinon = require('sinon');
var User = require('models/user');
var View = require('views/settings/devices');
describe('views/settings/devices', function () {
var devices;
var notifier;
var parentView;
var user;
var view;
var able;
function initView () {
view = new View({
able: able,
devices: devices,
notifier: notifier,
parentView: parentView,
user: user
});
return view.render();
}
function setupReRenderTest(testAction) {
return initView()
.then(function () {
var deferred = p.defer();
view.on('rendered', deferred.resolve.bind(deferred));
if (_.isFunction(testAction)) {
testAction();
}
return deferred.promise;
});
}
beforeEach(function () {
able = new Able();
sinon.stub(able, 'choose', function () {
return true;
});
notifier = new Notifier();
parentView = new BaseView();
user = new User();
devices = new Devices([
{
id: 'device-1',
isCurrentDevice: false,
name: 'alpha'
},
{
id: 'device-2',
isCurrentDevice: true,
name: 'beta'
}
], {
notifier: notifier
});
});
afterEach(function () {
view.remove();
view.destroy();
view = null;
});
describe('constructor', function () {
beforeEach(function () {
view = new View({
notifier: notifier,
parentView: parentView,
user: user
});
});
it('creates a `Devices` instance if one not passed in', function () {
assert.ok(view._devices);
});
});
describe('render', function () {
beforeEach(function () {
sinon.spy(devices, 'fetch');
return initView();
});
it('does not fetch the device list immediately to avoid startup XHR requests', function () {
assert.isFalse(devices.fetch.called);
});
it('renders devices already in the collection', function () {
assert.ok(view.$('li.device').length, 2);
});
});
describe('device added to collection', function () {
beforeEach(function () {
return setupReRenderTest(function () {
devices.add({
id: 'device-3',
name: 'delta'
});
});
});
it('adds new device to list', function () {
assert.lengthOf(view.$('li.device'), 3);
assert.include(view.$('#device-3 .device-name').text().trim(), 'delta');
});
});
describe('device removed from collection', function () {
beforeEach(function () {
return setupReRenderTest(function () {
// DOM needs written so that device remove animation completes
$('#container').html(view.el);
devices.get('device-1').destroy();
});
});
it('removes device from list', function () {
assert.lengthOf(view.$('li.device'), 1);
assert.lengthOf(view.$('#device-2'), 1);
});
});
describe('openPanel', function () {
beforeEach(function () {
return initView()
.then(function () {
sinon.stub(view, '_fetchDevices', function () {
});
return view.openPanel();
});
});
it('fetches the device list', function () {
assert.isTrue(view._fetchDevices.called);
});
});
describe('click to disconnect device', function () {
beforeEach(function () {
return initView()
.then(function () {
// click events require the view to be in the DOM
$('#container').html(view.el);
sinon.stub(view, '_destroyDevice', function () {
return p();
});
$('#device-2 .device-disconnect').click();
});
});
it('calls `_destroyDevice` with the deviceId', function () {
assert.isTrue(view._destroyDevice.calledWith('device-2'));
});
});
describe('refresh list behaviour', function () {
beforeEach(function () {
return initView()
.then(function () {
// click events require the view to be in the DOM
$('#container').html(view.el);
sinon.stub(view, '_fetchDevices', function () {
return p();
});
});
});
it('calls `_fetchDevices` using a button', function () {
sinon.stub(view, 'isPanelOpen', function () {
return true;
});
$('.devices-refresh').click();
assert.isTrue(view._fetchDevices.called);
});
});
describe('_fetchDevices', function () {
beforeEach(function () {
sinon.stub(user, 'fetchAccountDevices', function () {
return p();
});
return initView()
.then(function () {
return view._fetchDevices();
});
});
it('delegates to the user to fetch the device list', function () {
var account = view.getSignedInAccount();
assert.isTrue(user.fetchAccountDevices.calledWith(account, devices));
});
});
describe('_destroyDevice', function () {
var deviceToDestroy;
beforeEach(function () {
sinon.stub(user, 'destroyAccountDevice', function () {
return p();
});
return initView()
.then(function () {
deviceToDestroy = devices.get('device-1');
return view._destroyDevice('device-1');
});
});
it('delegates to the user to destroy the device', function () {
var account = view.getSignedInAccount();
assert.isTrue(
user.destroyAccountDevice.calledWith(account, deviceToDestroy));
});
});
});
});

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

@ -83,6 +83,7 @@ function (Translator, Session) {
'../tests/spec/views/settings/change_password',
'../tests/spec/views/settings/communication_preferences',
'../tests/spec/views/settings/delete_account',
'../tests/spec/views/settings/devices',
'../tests/spec/views/settings/display_name',
'../tests/spec/views/settings/gravatar_permissions',
'../tests/spec/views/sub_panels',
@ -134,6 +135,8 @@ function (Translator, Session) {
'../tests/spec/models/unique-user-id',
'../tests/spec/models/user',
'../tests/spec/models/account',
'../tests/spec/models/device',
'../tests/spec/models/devices',
'../tests/spec/models/profile-image',
'../tests/spec/models/email-resend',
'../tests/spec/models/form-prefill',

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

@ -112,6 +112,7 @@ module.exports = function (config, i18n) {
'/settings/change_password',
'/settings/communication_preferences',
'/settings/delete_account',
'/settings/devices',
'/settings/display_name',
'/signin',
'/signin_complete',

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

@ -31,6 +31,7 @@ define([
'./functional/robots_txt',
'./functional/settings',
'./functional/settings_common',
'./functional/settings_devices',
'./functional/sync_settings',
'./functional/change_password',
'./functional/force_auth',

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

@ -53,6 +53,7 @@ define([
'settings/change_password',
'settings/communication_preferences',
'settings/delete_account',
'settings/devices',
'settings/display_name',
'signin',
'signin_complete',

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

@ -0,0 +1,154 @@
/* 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([
'intern',
'intern/chai!assert',
'intern!object',
'intern/node_modules/dojo/node!xmlhttprequest',
'app/bower_components/fxa-js-client/fxa-client',
'tests/lib/helpers',
'tests/functional/lib/helpers'
], function (intern, assert, registerSuite, nodeXMLHttpRequest,
FxaClient, TestHelpers, FunctionalHelpers) {
var config = intern.config;
var AUTH_SERVER_ROOT = config.fxaAuthRoot;
var SIGNIN_URL = config.fxaContentRoot + 'signin';
var SIGNIN_URL_DEVICE_LIST = SIGNIN_URL + '?forceDeviceList=1';
var ANIMATION_TIME = 1000;
var FIRST_PASSWORD = 'password';
var BROWSER_DEVICE_NAME = 'Browser Session Device';
var BROWSER_DEVICE_TYPE = 'desktop';
var TEST_DEVICE_NAME = 'Test Runner Session Device';
var TEST_DEVICE_TYPE = 'mobile';
var email;
var client;
var accountData;
registerSuite({
name: 'settings devices',
beforeEach: function () {
email = TestHelpers.createEmail();
client = new FxaClient(AUTH_SERVER_ROOT, {
xhr: nodeXMLHttpRequest.XMLHttpRequest
});
var self = this;
return client.signUp(email, FIRST_PASSWORD, {preVerified: true})
.then(function (result) {
accountData = result;
return FunctionalHelpers.clearBrowserState(self);
});
},
afterEach: function () {
return FunctionalHelpers.clearBrowserState(this);
},
'device panel is not visible without query param': function () {
var self = this;
return FunctionalHelpers.openPage(this, SIGNIN_URL, '#fxa-signin-header')
.then(function () {
return FunctionalHelpers.fillOutSignIn(self, email, FIRST_PASSWORD);
})
.findByCssSelector('#fxa-settings-header')
.end()
.then(FunctionalHelpers.noSuchElement(this, '#devices'));
},
'device panel works with query param, same device': function () {
var self = this;
return FunctionalHelpers.openPage(this, SIGNIN_URL_DEVICE_LIST, '#fxa-signin-header')
.then(function () {
return FunctionalHelpers.fillOutSignIn(self, email, FIRST_PASSWORD);
})
.findByCssSelector('#fxa-settings-header')
.end()
.findByCssSelector('#devices .settings-unit-stub button')
.click()
.end()
.findByCssSelector('.devices-refresh')
.click()
.end()
// add a device from the test runner
.then(function () {
return client.deviceRegister(
accountData.sessionToken,
TEST_DEVICE_NAME,
TEST_DEVICE_TYPE
);
})
.findByCssSelector('.devices-refresh')
.click()
.end()
.findByCssSelector('.device-name')
.getVisibleText()
.then(function (val) {
assert.equal(val, TEST_DEVICE_NAME, 'device name is correct');
})
.end()
// add a device using a session token from the browser
.execute(function (uid) {
var accounts = JSON.parse(localStorage.getItem('__fxa_storage.accounts'));
return accounts[uid];
}, [ accountData.uid ])
.then(function (browserAccount) {
return client.deviceRegister(
browserAccount.sessionToken,
BROWSER_DEVICE_NAME,
BROWSER_DEVICE_TYPE
);
})
.findByCssSelector('.devices-refresh')
.click()
.end()
// wait for 2 devices
.findByCssSelector('.device:nth-child(2)')
.end()
// browser device should be sorted first
.findByCssSelector('.device:nth-child(1) .device-name')
.getVisibleText()
.then(function (val) {
assert.equal(val, BROWSER_DEVICE_NAME + ' (current)', 'device name is correct');
})
.end()
// clicking disconnect on the second device should update the list
.findByCssSelector('.device:nth-child(2) .device-disconnect')
.click()
.end()
.sleep(ANIMATION_TIME)
.findByCssSelector('.devices-refresh')
.click()
.end()
.then(FunctionalHelpers.noSuchElement(this, '.device:nth-child(2)'))
// clicking disconnect on the current device should sign you out
.findByCssSelector('.device:nth-child(1) .device-disconnect')
.click()
.end()
.findByCssSelector('#fxa-signin-header')
.end();
}
});
});

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

@ -31,6 +31,7 @@ define([
'tests/functional/robots_txt',
'tests/functional/settings',
'tests/functional/settings_common',
'tests/functional/settings_devices',
'tests/functional/sync_settings',
'tests/functional/change_password',
'tests/functional/force_auth',

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

@ -59,6 +59,7 @@ define([
'/settings/avatar/gravatar_permissions': { statusCode: 200 },
'/settings/change_password': { statusCode: 200 },
'/settings/delete_account': { statusCode: 200 },
'/settings/devices': { statusCode: 200 },
'/settings/display_name': { statusCode: 200 },
'/signin': { statusCode: 200 },
'/signin_complete': { statusCode: 200 },