fix(client): Hide all visible passwords on form submit. (#3969)

* fix(password): save passwords as passwords, not text
Fixes #3799

* fix(client): Hide all visible passwords on form submit.

This builds on @TDA's work.

Before the form is submit, convert all visible passwords from
[type=text] to [type=password] so that they are not saved as
form data.

In the process, I noticed two problems that are fixed:

* The cursor was not being correctly replaced to its original position
  once a password field was converted.
* If `setPasswordVisibility` was called programatically to toggle the
  state of the password field, the corresponding "Show" checkbox was
  not kept in sync.

fixes #3799

* chore(client): `wekbit` => `webkit`


r=@vladikoff
This commit is contained in:
Shane Tomlinson 2016-07-25 19:30:29 +01:00 коммит произвёл GitHub
Родитель b271bceb16
Коммит a6c848cc9f
7 изменённых файлов: 255 добавлений и 84 удалений

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

@ -670,18 +670,14 @@ define(function (require, exports, module) {
/**
* Safely focus an element
*/
focus: function (which) {
focus (which) {
try {
var focusEl = this.$(which);
const focusEl = this.$(which);
// place the cursor at the end of the input when the
// element is focused.
var self = this;
focusEl.one('focus', function () {
try {
this.selectionStart = this.selectionEnd = this.value.length;
} catch (e) {
// This can blow up on password fields in Chrome. Drop the error on
// the ground, for whatever reason, it still behaves as we expect.
}
self.placeCursorAt(this, this.value.length);
});
focusEl.get(0).focus();
} catch (e) {
@ -689,6 +685,25 @@ define(function (require, exports, module) {
}
},
/**
* Place the cursor at the given position within the input element
*
* @param {selector | element} which
* @param {number} selectionStart - defaults to 0.
* @param {number} selectionEnd - defaults to selectionStart.
*/
placeCursorAt (which, selectionStart = 0, selectionEnd = selectionStart) {
const el = $(which).get(0);
try {
el.selectionStart = selectionStart;
el.selectionEnd = selectionEnd;
} catch (e) {
// This can blow up on password fields in Chrome. Drop the error on
// the ground, for whatever reason, it still behaves as we expect.
}
},
/**
* Invoke the specified handler with the given event. Handler
* can either be a function or a string. If a string, looks for

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

@ -172,6 +172,8 @@ define(function (require, exports, module) {
event.stopImmediatePropagation();
}
this.trigger('submitStart');
return p()
.then(function () {
if (self.isHalted()) {

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

@ -7,7 +7,8 @@
define(function (require, exports, module) {
'use strict';
var Constants = require('lib/constants');
const $ = require('jquery');
const Constants = require('lib/constants');
module.exports = {
events: {
@ -15,7 +16,15 @@ define(function (require, exports, module) {
'keyup input.password': 'onPasswordKeyUp'
},
afterVisible: function () {
initialize () {
// An internal submitStart event is listened for instead of
// the `submit` DOM event because form.js already binds a submit
// event. Because of the way Cocktail wraps colliding functions,
// the form is always submit if a second event handler is added.
this.on('submitStart', () => this.hideVisiblePasswords());
},
afterVisible () {
if (this.isInExperiment && this.isInExperiment('showPassword')) {
this.notifier.trigger('showPassword.triggered');
@ -25,7 +34,7 @@ define(function (require, exports, module) {
}
},
onPasswordVisibilityChange: function (event) {
onPasswordVisibilityChange (event) {
var target = this.$(event.target);
this.setPasswordVisibilityFromButton(target);
@ -39,39 +48,80 @@ define(function (require, exports, module) {
this.focus(controlsSelector);
},
setPasswordVisibilityFromButton: function (button) {
setPasswordVisibilityFromButton (button) {
var isVisible = this.$(button).is(':checked');
var targets = this.getAffectedPasswordInputs(button);
this.setPasswordVisibility(isVisible, targets);
this.setPasswordVisibility(targets, isVisible);
},
getAffectedPasswordInputs: function (button) {
var passwordField = this.$(button).siblings('.password');
getAffectedPasswordInputs (button) {
var $passwordEl = this.$(button).siblings('.password');
if (this.$(button).data('synchronizeShow')) {
passwordField = this.$('.password');
$passwordEl = this.$('.password');
}
return passwordField;
return $passwordEl;
},
setPasswordVisibility: function (isVisible, passwordField) {
/**
* Set the password visibility for an element. Ensure the "show" button's
* state is synchronized.
*
* @param {selector | element} which
* @param {boolean} isVisible
*/
setPasswordVisibility (which, isVisible) {
const $passwordEl = $(which);
const $showPasswordEl = $passwordEl.siblings('.show-password');
// Store the cursor position before updating the element type
// or else the cursor is set to before the first character.
// `which` can match more than one element. If this is the case
// find the element that is focused.
let $focusedEl = $passwordEl.filter(':focus');
let selectionStart;
let selectionEnd;
if ($focusedEl.length) {
const focusedEl = $focusedEl.get(0);
selectionStart = focusedEl.selectionStart;
selectionEnd = focusedEl.selectionEnd;
}
try {
if (isVisible) {
passwordField.attr('type', 'text').attr('autocomplete', 'off')
$passwordEl.attr('type', 'text').attr('autocomplete', 'off')
.attr('autocorrect', 'off').attr('autocapitalize', 'off');
$showPasswordEl.attr('checked', true);
this.logViewEvent('password.visible');
} else {
passwordField.attr('type', 'password');
passwordField.removeAttr('autocomplete')
.removeAttr('autocorrect').removeAttr('autocapitalize');
$passwordEl.attr('type', 'password').removeAttr('autocomplete')
.removeAttr('autocorrect').removeAttr('autocapitalize');
$showPasswordEl.removeAttr('checked');
this.logViewEvent('password.hidden');
}
} catch (e) {
// IE8 blows up when changing the type of the input field. Other
// browsers might too. Ignore the error.
}
// Restore the cursor position if the element is in focus.
if ($focusedEl.length) {
this.placeCursorAt($focusedEl, selectionStart, selectionEnd);
}
},
onPasswordKeyUp: function () {
/**
* Hide all visible passwords to prevent passwords from being saved
* by the browser as text form data.
*/
hideVisiblePasswords () {
this.$el.find('.password[type=text]').each((index, el) => {
this.setPasswordVisibility(el, false);
});
},
onPasswordKeyUp () {
var values = [];
// Values contains all password classes length
@ -88,11 +138,11 @@ define(function (require, exports, module) {
}
},
showPasswordHelper: function () {
showPasswordHelper () {
this.$('.input-help').css('opacity', '1');
},
hidePasswordHelper: function () {
hidePasswordHelper () {
// Hide all input-help classes except input-help-forgot-pw
this.$('.input-help:not(.input-help-forgot-pw)').css('opacity', '0');
}

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

@ -11,6 +11,23 @@ define(function (require, exports, module) {
var ProfileMock = require('../mocks/profile.js');
var sinon = require('sinon');
function noOp () {}
function ifDocumentFocused(callback, done = noOp) {
if (document.hasFocus && document.hasFocus()) {
callback();
} else {
const message =
'Cannot check for focus - document does not have focus.\n' +
'If this is in Travis-CI, Sauce Labs, or Opera, this is expected.\n' +
'Otherwise, try focusing the test document instead of \n' +
'another window or dev tools.';
console.warn(message);
done();
}
}
function requiresFocus(callback, done) {
// Give the document focus
window.focus();
@ -20,20 +37,7 @@ define(function (require, exports, module) {
document.activeElement.blur();
}
if (document.hasFocus && document.hasFocus()) {
callback();
} else {
var message =
'Cannot check for focus - document does not have focus.\n' +
'If this is in Travis-CI, Sauce Labs, or Opera, this is expected.\n' +
'Otherwise, try focusing the test document instead of \n' +
'another window or dev tools.';
console.warn(message);
if (done) {
done();
}
}
ifDocumentFocused(callback, done);
}
function addFxaClientSpy(fxaClient) {
@ -164,6 +168,7 @@ define(function (require, exports, module) {
createUid: createUid,
emailToUser: emailToUser,
getValueLabel: getValueLabel,
ifDocumentFocused: ifDocumentFocused,
indexOfEvent: indexOfEvent,
isErrorLogged: isErrorLogged,
isEventLogged: isEventLogged,

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

@ -361,7 +361,7 @@ define(function (require, exports, module) {
it('focuses descendent element containing `autofocus` if html has `no-touch` class', function (done) {
requiresFocus(function () {
$('html').addClass('no-touch');
// wekbit fails unless focusing another element first.
// webkit fails unless focusing another element first.
$('#otherElement').focus();
var handlerCalled = false;
@ -589,20 +589,54 @@ define(function (require, exports, module) {
});
});
describe('focus', function () {
it('focuses an element', function (done) {
requiresFocus(function () {
// wekbit fails unless focusing another element first.
describe('focus', () => {
it('focuses an element, sets the cursor position', (done) => {
requiresFocus(() => {
// webkit fails unless focusing another element first.
$('#otherElement').focus();
view.$('#focusMe').one('focus', function () {
const elText = 'some text';
const $focusEl = view.$('#focusMe').val(elText);
// Use setTimeout because the selection is set in a `focus` handler
// and this `focus` handler is run first.
view.$('#focusMe').one('focus', () => setTimeout(() => {
const focusEl = $focusEl.get(0);
assert.equal(focusEl.selectionStart, elText.length);
assert.equal(focusEl.selectionEnd, elText.length);
done();
});
}, 0));
view.focus('#focusMe');
}, done);
});
});
describe('placeCursorAt', () => {
const elText = 'some text';
let $focusEl;
let focusEl;
beforeEach(() => {
$focusEl = view.$('#focusMe').val(elText);
focusEl = $focusEl.get(0);
});
it('places the cursor at the specified position if only start given', () => {
view.placeCursorAt('#focusMe', elText.length);
assert.equal(focusEl.selectionStart, elText.length);
assert.equal(focusEl.selectionEnd, elText.length);
});
it('places the cursor at the specified selection if both given', () => {
view.placeCursorAt('#focusMe', elText.length - 2, elText.length);
assert.equal(focusEl.selectionStart, elText.length - 2);
assert.equal(focusEl.selectionEnd, elText.length);
});
});
describe('trackChildView/untrackChildView', function () {
it('trackChildView tracks a childView, untrackChildView untracks the childView', function () {
var childView = new BaseView();

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

@ -164,6 +164,12 @@ define(function (require, exports, module) {
});
describe('validateAndSubmit', function () {
it('triggers a `submitStart` event', (done) => {
view.on('submitStart', () => done());
view.validateAndSubmit();
});
it('submits form if isValid returns true', function () {
view.formIsValid = true;
view.enableSubmitIfValid();

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

@ -5,24 +5,23 @@
define(function (require, exports, module) {
'use strict';
var $ = require('jquery');
var BaseView = require('views/base');
var chai = require('chai');
var Cocktail = require('cocktail');
var ExperimentMixin = require('views/mixins/experiment-mixin');
var Metrics = require('lib/metrics');
var Notifier = require('lib/channels/notifier');
var PasswordMixin = require('views/mixins/password-mixin');
var Relier = require('models/reliers/relier');
var sinon = require('sinon');
var TestHelpers = require('../../../lib/helpers');
var TestTemplate = require('stache!templates/test_template');
const $ = require('jquery');
const assert = require('chai').assert;
const BaseView = require('views/base');
const Cocktail = require('cocktail');
const ExperimentMixin = require('views/mixins/experiment-mixin');
const Metrics = require('lib/metrics');
const Notifier = require('lib/channels/notifier');
const PasswordMixin = require('views/mixins/password-mixin');
const Relier = require('models/reliers/relier');
const sinon = require('sinon');
const TestHelpers = require('../../../lib/helpers');
const TestTemplate = require('stache!templates/test_template');
var assert = chai.assert;
var PasswordView = BaseView.extend({
const PasswordView = BaseView.extend({
template: TestTemplate
});
Cocktail.mixin(
PasswordView,
PasswordMixin,
@ -30,9 +29,9 @@ define(function (require, exports, module) {
);
describe('views/mixins/password-mixin', function () {
var view;
var relier;
var metrics;
let metrics;
let relier;
let view;
beforeEach(function () {
relier = new Relier();
@ -94,7 +93,6 @@ define(function (require, exports, module) {
view.afterVisible();
assert.isFalse(view.$('.show-password-label').is(':hidden'));
});
});
describe('onPasswordVisibilityChange', function () {
@ -114,27 +112,69 @@ define(function (require, exports, module) {
});
});
describe('setPasswordVisibility', () => {
it('to visible', () => {
const $passwordEl = view.$('#password');
$passwordEl.val('password').focus();
const selectionStart = 1;
const selectionEnd = 2;
const passwordEl = $passwordEl.get(0);
passwordEl.selectionStart = selectionStart;
passwordEl.selectionEnd = selectionEnd;
view.setPasswordVisibility('#password', true);
assert.equal($passwordEl.attr('type'), 'text');
assert.equal($passwordEl.attr('autocapitalize'), 'off');
assert.equal($passwordEl.attr('autocorrect'), 'off');
// Ensure the cursor is replaced to its original position.
// Only make the check if the document has focus, otherwise
// the test fails. Document may not have focus if the dev
// clicks out of the test window or when run in PhantomJS.
TestHelpers.ifDocumentFocused(() => {
assert.equal(passwordEl.selectionStart, selectionStart);
assert.equal(passwordEl.selectionEnd, selectionEnd);
});
// Ensure the show password state stays in sync
const $showPasswordEl = $passwordEl.siblings('.show-password');
assert.isTrue($showPasswordEl.is(':checked'));
});
it('to hidden', () => {
view.setPasswordVisibility('#password', false);
const $passwordEl = view.$('#password');
assert.equal($passwordEl.attr('autocomplete'), null);
assert.equal($passwordEl.attr('autocapitalize'), null);
assert.equal($passwordEl.attr('autocorrect'), null);
// Ensure the show password state stays in sync
const $showPasswordEl = $passwordEl.siblings('.show-password');
assert.isFalse($showPasswordEl.is(':checked'));
});
});
describe('setPasswordVisibilityFromButton', function () {
it('sets the password field to type=text if button is checked', function () {
view.$('#show-password').attr('checked', 'checked');
view.setPasswordVisibilityFromButton('#show-password');
assert.equal(view.$('#password').attr('type'), 'text');
assert.equal(view.$('#password').attr('autocapitalize'), 'off');
assert.equal(view.$('#password').attr('autocorrect'), 'off');
});
it('sets the password field to type=password if button is unchecked', function () {
view.$('#show-password').removeAttr('checked');
view.setPasswordVisibilityFromButton('#show-password');
assert.equal(view.$('#password').attr('type'), 'password');
assert.isUndefined(view.$('#password').attr('autocapitalize'));
assert.isUndefined(view.$('#password').attr('autocorrect'));
});
});
describe('clicking on unsynched/synched show buttons', function () {
it('gets pwd inputs to be shown', function () {
var targets = view.getAffectedPasswordInputs('#show-password');
it('gets password inputs to be shown', function () {
let targets = view.getAffectedPasswordInputs('#show-password');
assert.equal(targets.length, 1);
view.$('#show-password').data('synchronize-show', 'true');
@ -147,26 +187,14 @@ define(function (require, exports, module) {
it('pw field set to text when clicked', function () {
view.$('.show-password').click();
assert.equal(view.$('#password').attr('type'), 'text');
assert.equal(view.$('#password').attr('autocomplete'), 'off');
assert.equal(view.$('#password').attr('autocapitalize'), 'off');
assert.equal(view.$('#password').attr('autocorrect'), 'off');
assert.equal(view.$('#vpassword').attr('type'), 'text');
assert.equal(view.$('#vpassword').attr('autocomplete'), 'off');
assert.equal(view.$('#vpassword').attr('autocomplete'), 'off');
assert.equal(view.$('#vpassword').attr('autocapitalize'), 'off');
});
it('pw field set to password when clicked again', function () {
view.$('.show-password').click();
view.$('.show-password').click();
assert.equal(view.$('#password').attr('type'), 'password');
assert.equal(view.$('#password').attr('autocomplete'), null);
assert.equal(view.$('#password').attr('autocapitalize'), null);
assert.equal(view.$('#password').attr('autocorrect'), null);
assert.equal($('#vpassword').attr('type'), 'password');
assert.equal($('#vpassword').attr('autocomplete'), null);
assert.equal($('#vpassword').attr('autocapitalize'), null);
assert.equal($('#vpassword').attr('autocorrect'), null);
assert.equal(view.$('#vpassword').attr('type'), 'password');
});
it('logs whether the password is shown or hidden', function () {
@ -206,5 +234,36 @@ define(function (require, exports, module) {
assert.equal(view.$('.input-help-forgot-pw').css('opacity'), '1');
});
});
describe('hideVisiblePasswords', () => {
it('sets all password fields to type `password`', () => {
const $passwordEls = view.$('.password');
assert.equal($passwordEls.length, 2);
$passwordEls.each((index, el) => {
view.setPasswordVisibility(el, true);
assert.equal(el.type, 'text');
});
view.hideVisiblePasswords();
$passwordEls.each((i, el) => {
assert.equal(el.type, 'password');
});
});
});
describe('submitStart event', () => {
beforeEach(() => {
sinon.spy(view, 'hideVisiblePasswords');
});
it('hides all visible passwords', () => {
assert.equal(view.hideVisiblePasswords.callCount, 0);
view.trigger('submitStart');
assert.equal(view.hideVisiblePasswords.callCount, 1);
});
});
});
});