diff --git a/lib/db/json.js b/lib/db/json.js index e49615c1fb4f5c2f495c9246cb8bcb4dbb695684..376a2a791cae9a968949a202772df5c2193d552f 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -236,7 +236,7 @@ exports.emailForVerificationSecret = function(secret, cb) { process.nextTick(function() { sync(); if (!db.staged[secret]) return cb("no such secret"); - cb(null, db.staged[secret].email, db.staged[secret].existing_user); + cb(null, db.staged[secret].email, db.staged[secret].existing_user, db.staged[secret].passwd); }); }; diff --git a/lib/db/mysql.js b/lib/db/mysql.js index b2e123ae6667e7c150963f627f0af30ba9c491bc..8c2d805e63fed01488e4b6f82b561198c98369c1 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -265,14 +265,14 @@ exports.haveVerificationSecret = function(secret, cb) { exports.emailForVerificationSecret = function(secret, cb) { client.query( - "SELECT email, existing_user FROM staged WHERE secret = ?", [ secret ], + "SELECT email, existing_user, passwd FROM staged WHERE secret = ?", [ secret ], function(err, rows) { if (err) return cb("database unavailable"); // if the record was not found, fail out if (!rows || rows.length != 1) return cb("no such secret"); - cb(null, rows[0].email, rows[0].existing_user); + cb(null, rows[0].email, rows[0].existing_user, rows[0].passwd); }); }; @@ -291,7 +291,7 @@ exports.authForVerificationSecret = function(secret, cb) { if (o.passwd) return cb(null, o.passwd, o.existing_user); // otherwise, let's get the passwd from the user record - if (!o.existing_user) cb("no password for user"); + if (!o.existing_user) return cb("no password for user"); exports.checkAuth(o.existing_user, function(err, hash) { cb(err, hash, o.existing_user); @@ -337,8 +337,9 @@ exports.gotVerificationSecret = function(secret, cb) { if (err) { logUnexpectedError(err); cb(err); - } else if (rows.length === 0) cb("unknown secret"); - else { + } else if (rows.length === 0) { + cb("unknown secret"); + } else { var o = rows[0]; // delete the record diff --git a/lib/static_resources.js b/lib/static_resources.js index 15fce2a8b3782857152b448697115ef8c3d19931..92f5c95cefa19b66e9206ed015cf9f95de18d371 100644 --- a/lib/static_resources.js +++ b/lib/static_resources.js @@ -80,6 +80,9 @@ var dialog_js = und.flatten([ '/shared/history.js', '/shared/state_machine.js', + '/shared/models/models.js', + '/shared/models/interaction_data.js', + '/shared/modules/interaction_data.js', '/dialog/resources/internal_api.js', diff --git a/lib/wsapi/complete_email_addition.js b/lib/wsapi/complete_email_addition.js index fcff7281387b49d78fb53a05ad4b74187dd36db2..7756fcf5b9cc8bb06cc14c7ba7768901854c6cad 100644 --- a/lib/wsapi/complete_email_addition.js +++ b/lib/wsapi/complete_email_addition.js @@ -21,13 +21,40 @@ exports.process = function(req, res) { // // 1. you must already be authenticated as the user who initiated the verification // 2. you must provide the password of the initiator. - // + + // TRANSITIONAL CODE COMMENT + // for issue 1000 we moved initial password selection to the browserid dialog (from + // the verification page). Rolling out this change causes some temporal pain. + // Outstannding verification links sent before the change was deployed will have + // email addition requests that require passwords without passwords in the stage table. + // When the verification page is loaded for + // these links, we prompt the user for a password. That password is sent up with + // the request. this code and comment should all be purged after the new code + // has been in production for 2 weeks. + + var transitionalPassword = null; + + // END TRANSITIONAL CODE COMMENT + + db.authForVerificationSecret(req.body.token, function(err, initiator_hash, initiator_uid) { if (err) { logger.info("unknown verification secret: " + err); return wsapi.databaseDown(res, err); } + // TRANSITIONAL CODE + if (!initiator_hash) { + if (!req.body.pass) return httputils.authRequired(res, "password required"); + var err = wsapi.checkPassword(req.body.pass); + if (err) { + logger.warn("invalid password received: " + err); + return httputils.badRequest(res, err); + } + transitionalPassword = req.body.pass; + postAuthentication(); + } else + // END TRANSITIONAL CODE if (req.session.userid === initiator_uid) { postAuthentication(); } else if (typeof req.body.pass === 'string') { @@ -53,6 +80,23 @@ exports.process = function(req, res) { } else { wsapi.authenticateSession(req.session, uid, 'password'); res.json({ success: true }); + + // TRANSITIONAL CODE + if (transitionalPassword) { + wsapi.bcryptPassword(transitionalPassword, function(err, hash) { + if (err) { + logger.warn("couldn't bcrypt pass for old verification link: " + err); + return; + } + + db.updatePassword(uid, hash, function(err) { + if (err) { + logger.warn("couldn't bcrypt pass for old verification link: " + err); + } + }); + }); + } + // END TRANSITIONAL CODE } }); }; diff --git a/lib/wsapi/complete_user_creation.js b/lib/wsapi/complete_user_creation.js index e507e0f95e1396268a4f180100f00191ee469656..5b87c3ec9af756562a37076fb88a2e9f09713f99 100644 --- a/lib/wsapi/complete_user_creation.js +++ b/lib/wsapi/complete_user_creation.js @@ -28,18 +28,36 @@ exports.process = function(req, res) { // and then control a browserid account that they can use to prove they own // the email address of the attacked. + // TRANSITIONAL CODE COMMENT + // for issue 1000 we moved initial password selection to the browserid dialog (from + // the verification page). Rolling out this change causes some temporal pain. + // Outstannding verification links sent before the change was deployed will have + // new user requests without passwords. When the verification page is loaded for + // these links, we prompt the user for a password. That password is sent up with + // the request. this code and comment should all be purged after the new code + // has been in production for 2 weeks. + // END TRANSITIONAL CODE COMMENT + // is this the same browser? if (typeof req.session.pendingCreation === 'string' && req.body.token === req.session.pendingCreation) { - postAuthentication(); + return postAuthentication(); } // is a password provided? else if (typeof req.body.pass === 'string') { return db.authForVerificationSecret(req.body.token, function(err, hash) { + // TRANSITIONAL CODE + // if hash is null, no password was provided during verification and + // this is an old-style verification. We accept the password and will + // update it after the verification is complete. + if (err == 'no password for user' || !hash) return postAuthentication(); + // END TRANSITIONAL CODE + if (err) { logger.warn("couldn't get password for verification secret: " + err); return wsapi.databaseDown(res, err); } + bcrypt.compare(req.body.pass, hash, function (err, success) { if (err) { logger.warn("max load hit, failing on auth request with 503: " + err); @@ -47,7 +65,7 @@ exports.process = function(req, res) { } else if (!success) { return httputils.authRequired(res, "password mismatch"); } else { - postAuthentication(); + return postAuthentication(); } }); }); @@ -65,19 +83,58 @@ exports.process = function(req, res) { if (!known) return res.json({ success: false} ); - db.gotVerificationSecret(req.body.token, function(err, email, uid) { - if (err) { - logger.warn("couldn't complete email verification: " + err); - wsapi.databaseDown(res, err); - } else { - // FIXME: not sure if we want to do this (ba) - // at this point the user has set a password associated with an email address - // that they've verified. We create an authenticated session. - wsapi.authenticateSession(req.session, uid, 'password', - config.get('ephemeral_session_duration_ms')); - res.json({ success: true }); + // TRANSITIONAL CODE + // user is authorized (1 or 2 above) OR user has no password set, in which + // case for a short time we'll accept the password provided with the verification + // link, and set it as theirs. + var transitionalPassword = null; + + db.authForVerificationSecret(req.body.token, function(err, hash) { + if (err == 'no password for user' || !hash) { + if (!req.body.pass) return httputils.authRequired(res, "password required"); + err = wsapi.checkPassword(req.body.pass); + if (err) { + logger.warn("invalid password received: " + err); + return httputils.badRequest(res, err); + } + transitionalPassword = req.body.pass; } + completeCreation(); }); + // END TRANSITIONAL CODE + + function completeCreation() { + db.gotVerificationSecret(req.body.token, function(err, email, uid) { + if (err) { + logger.warn("couldn't complete email verification: " + err); + wsapi.databaseDown(res, err); + } else { + // FIXME: not sure if we want to do this (ba) + // at this point the user has set a password associated with an email address + // that they've verified. We create an authenticated session. + wsapi.authenticateSession(req.session, uid, 'password', + config.get('ephemeral_session_duration_ms')); + res.json({ success: true }); + + // TRANSITIONAL CODE + if (transitionalPassword) { + wsapi.bcryptPassword(transitionalPassword, function(err, hash) { + if (err) { + logger.warn("couldn't bcrypt pass for old verification link: " + err); + return; + } + + db.updatePassword(uid, hash, function(err) { + if (err) { + logger.warn("couldn't bcrypt pass for old verification link: " + err); + } + }); + }); + } + // END TRANSITIONAL CODE + } + }); + } }); } }; diff --git a/lib/wsapi/email_for_token.js b/lib/wsapi/email_for_token.js index f492bcff595e0978c7f96e24f31b287fdceb8851..ac0a5d9543514ac4cb74c1ab3986eded0ad63421 100644 --- a/lib/wsapi/email_for_token.js +++ b/lib/wsapi/email_for_token.js @@ -19,7 +19,7 @@ exports.args = ['token']; exports.i18n = false; exports.process = function(req, res) { - db.emailForVerificationSecret(req.query.token, function(err, email, uid) { + db.emailForVerificationSecret(req.query.token, function(err, email, uid, hash) { if (err) { if (err === 'database unavailable') { httputils.serviceUnavailable(res, err); @@ -30,24 +30,64 @@ exports.process = function(req, res) { }); } } else { - // must the user authenticate? This is true if they are not authenticated - // as the uid who initiated the verification, and they are not on the same - // browser as the initiator - var must_auth = true; + function checkMustAuth() { + // must the user authenticate? This is true if they are not authenticated + // as the uid who initiated the verification, and they are not on the same + // browser as the initiator + var must_auth = true; - if (uid && req.session.userid === uid) { - must_auth = false; + if (uid && req.session.userid === uid) { + must_auth = false; + } + else if (!uid && typeof req.session.pendingCreation === 'string' && + req.query.token === req.session.pendingCreation) { + must_auth = false; + } + + res.json({ + success: true, + email: email, + must_auth: must_auth + }); + } + + // backwards compatibility - issue #1592 + // if there is no password in the user record, and no password in the staged + // table, then we require a password be fetched from the user upon verification. + // these checks are temporary and should disappear in 1 trains time. + function needsPassword() { + // no password is set neither in the user table nor in the staged record. + // the user must pick a password + res.json({ + success: true, + email: email, + needs_password: true + }); } - else if (!uid && typeof req.session.pendingCreation === 'string' && - req.query.token === req.session.pendingCreation) { - must_auth = false; + + if (!hash) { + if (!uid) { + needsPassword(); + } else { + db.checkAuth(uid, function(err, hash) { + if (err) { + return res.json({ + success: false, + reason: err + }); + } + + if (!hash) { + needsPassword(); + } else { + checkMustAuth(); + } + }); + } + } else { + checkMustAuth(); } - res.json({ - success: true, - email: email, - must_auth: must_auth - }); } }); }; diff --git a/resources/static/css/style.css b/resources/static/css/style.css index 86b0b219f6f3b529e0ba677130ce4f0d0271ad76..802295daca104265ab0c2bf4feb716cb39357683 100644 --- a/resources/static/css/style.css +++ b/resources/static/css/style.css @@ -669,7 +669,7 @@ h1 { margin-bottom: 10px; } -.siteinfo, #congrats, .password_entry, .enter_password .hint, #unknown_secondary, #primary_verify, .verify_primary .submit { +.siteinfo, #congrats, .password_entry, #verify_password, .enter_password .hint, #unknown_secondary, #primary_verify, .verify_primary .submit { display: none; } @@ -677,7 +677,7 @@ h1 { float: left; } -.enter_password .password_entry, .known_secondary .password_entry, +.enter_password .password_entry, .enter_verify_password #verify_password, .known_secondary .password_entry, .unknown_secondary #unknown_secondary, .verify_primary #verify_primary { display: block; } diff --git a/resources/static/pages/verify_secondary_address.js b/resources/static/pages/verify_secondary_address.js index 2ccfb0c804ec90c672114c6235fcbefee8b3e0df..607a5621c80d4b477de7ea572c4008b007d6a30a 100644 --- a/resources/static/pages/verify_secondary_address.js +++ b/resources/static/pages/verify_secondary_address.js @@ -17,6 +17,7 @@ BrowserID.verifySecondaryAddress = (function() { validation = bid.Validation, token, sc, + needsPassword, mustAuth, verifyFunction; @@ -36,7 +37,11 @@ BrowserID.verifySecondaryAddress = (function() { function submit(oncomplete) { var pass = dom.getInner("#password") || undefined, - valid = !mustAuth || validation.password(pass); + vpass = dom.getInner("#vpassword") || undefined, + valid = (!needsPassword || + validation.passwordAndValidationPassword(pass, vpass)) + && (!mustAuth || + validation.password(pass)); if (valid) { user[verifyFunction](token, pass, function(info) { @@ -56,13 +61,25 @@ BrowserID.verifySecondaryAddress = (function() { if(info) { showRegistrationInfo(info); + needsPassword = info.needs_password; mustAuth = info.must_auth; - if (mustAuth) { + if (needsPassword) { + // This is a fix for legacy users who started the user creation + // process without setting their password in the dialog. If the user + // needs a password, they must set it now. Once all legacy users are + // verified or their links invalidated, this flow can be removed. + dom.addClass("body", "enter_password"); + dom.addClass("body", "enter_verify_password"); + complete(oncomplete, true); + } + else if (mustAuth) { + // These are users who have set their passwords inside of the dialog. dom.addClass("body", "enter_password"); complete(oncomplete, true); } else { + // These are users who do not have to set their passwords at all. submit(oncomplete); } } diff --git a/resources/static/shared/models/interaction_data.js b/resources/static/shared/models/interaction_data.js new file mode 100644 index 0000000000000000000000000000000000000000..d5ad94544acbc97e3712c26c0025caa6eed3cd3f --- /dev/null +++ b/resources/static/shared/models/interaction_data.js @@ -0,0 +1,163 @@ +/*globals BrowserID: true */ +/* 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/. */ + +BrowserID.Models.InteractionData = (function() { + "use strict"; + + var bid = BrowserID, + storage = bid.getStorage(), + network = bid.Network, + complete = bid.Helpers.complete; + + function getInteractionData() { + var interactionData; + try { + interactionData = JSON.parse(storage.interaction_data); + } catch(e) { + } + + return interactionData || {}; + } + + function setInteractionData(data) { + try { + storage.interaction_data = JSON.stringify(data); + } catch(e) { + storage.removeItem("interaction_data"); + } + } + + function push(newData) { + stageCurrent(); + + var interactionData = getInteractionData(); + interactionData.current = newData; + + setInteractionData(interactionData); + } + + function getCurrent() { + var interactionData = getInteractionData(); + + return interactionData.current; + } + + function setCurrent(data) { + var interactionData = getInteractionData(); + interactionData.current = data; + setInteractionData(interactionData); + } + + function stageCurrent() { + // Push existing current data to the staged list. This allows + // us to get/clear the staged list without affecting the current data. + var interactionData = getInteractionData(); + + if (interactionData.current) { + var staged = interactionData.staged = interactionData.staged || []; + staged.unshift(interactionData.current); + + delete interactionData.current; + + setInteractionData(interactionData); + } + } + + function getStaged() { + var interactionData = getInteractionData(); + return interactionData.staged || []; + } + + function clearStaged() { + var interactionData = getInteractionData(); + delete interactionData.staged; + setInteractionData(interactionData); + } + + // We'll try to publish past interaction data to the server if it exists. + // The psuedo transactional model employed here is to attempt to post, and + // only once we receive a server response do we purge data. We don't + // care if the post is a success or failure as this data is not + // critical to the functioning of the system (and some failure scenarios + // simply won't resolve with retries - like corrupt data, or too much + // data) + function publishStaged(oncomplete) { + var data = getStaged(); + + // XXX: should we even try to post data if it's larger than some reasonable + // threshold? + if (data && data.length !== 0) { + network.sendInteractionData(data, function() { + clearStaged(); + complete(oncomplete, true); + }, function(status) { + // if the server returns a 413 error, (too much data posted), then + // let's clear our local storage and move on. This does mean we + // loose some interaction data, but it shouldn't be statistically + // significant. + if (status && status.network && status.network.status === 413) { + clearStaged(); + } + complete(oncomplete, false); + }); + } + else { + complete(oncomplete, false); + } + } + + return { + /** + * add a new interaction blob to localstorage, this will *push* any stored + * blobs to the 'staged' backlog, and happens when a new dialog interaction + * begins. + * @method push + * @param {object} data - an object to push onto the queue + * @returns nada + */ + push: push, + /** + * read the interaction data blob associated with the current interaction + * @method getCurrent + * @returns a JSON object containing the latest interaction data blob + */ + getCurrent: getCurrent, + /** + * overwrite the interaction data blob associated with the current interaction + * @method setCurrent + * @param {object} data - the object to overwrite current with + */ + setCurrent: setCurrent, + /** + * Shift any "current" data into the staged list. No data will be listed + * as current afterwards. + * @method stageCurrent + */ + stageCurrent: stageCurrent, + /** + * get all past saved interaction data (returned as a JSON array), excluding + * the "current" data (that which is being collected now). + * @method getStaged + * @returns an array, possibly of length zero if no past interaction data is + * available + */ + getStaged: getStaged, + /** + * publish staged data. Staged data will be cleared if successfully posted + * to server or if server returns 413 - too much data. + * @param {function} [oncomplete] - function to call when complete. Called + * with true if data was successfully sent to server, false otw. + * @method publishStaged + */ + publishStaged: publishStaged, + /** + * clear all interaction data, except the current, in-progress + * collection. + * @method clearStaged() + */ + clearStaged: clearStaged + }; + +}()); diff --git a/resources/static/shared/models/models.js b/resources/static/shared/models/models.js new file mode 100644 index 0000000000000000000000000000000000000000..845cc04763349d29296935b5148f22f7f37f5032 --- /dev/null +++ b/resources/static/shared/models/models.js @@ -0,0 +1,7 @@ +/*globals BrowserID: true */ +/* 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/. */ + +BrowserID.Models = {}; + diff --git a/resources/static/shared/modules/interaction_data.js b/resources/static/shared/modules/interaction_data.js index 1ad832138c3305ed70db299a482483b8f54efb41..0ef57fd67381c5180d8e61a0734124032d6bf1bd 100644 --- a/resources/static/shared/modules/interaction_data.js +++ b/resources/static/shared/modules/interaction_data.js @@ -24,7 +24,7 @@ BrowserID.Modules.InteractionData = (function() { var bid = BrowserID, - storage = bid.Storage.interactionData, + model = bid.Models.InteractionData, network = bid.Network, complete = bid.Helpers.complete, dom = bid.DOM, @@ -41,7 +41,9 @@ BrowserID.Modules.InteractionData = (function() { // session data must be published independently of whether the current // dialog session is allowed to sample data. This is because the original // dialog session has already decided whether to collect data. - publishStored(); + + model.stageCurrent(); + publishStored.call(self); // set the sample rate as defined by the server. It's a value // between 0..1, integer or float, and it specifies the percentage @@ -78,63 +80,63 @@ BrowserID.Modules.InteractionData = (function() { // as soon as the first session_context completes for the next dialog // session. Use a push because old data *may not* have been correctly // published to a down server or erroring web service. - storage.push(currentData); + model.push(currentData); self.initialEventStream = null; self.samplesBeingStored = true; } - // At every load, after session_context returns, we'll try to publish - // past interaction data to the server if it exists. The psuedo - // transactional model employed here is to attempt to post, and only - // once we receive a server response do we purge data. We don't - // care if the post is a success or failure as this data is not - // critical to the functioning of the system (and some failure scenarios - // simply won't resolve with retries - like corrupt data, or too much - // data) + // At every load, after session_context returns, try to publish the previous + // data. We have to wait until session_context completes so that we have + // a csrf token to send. function publishStored(oncomplete) { - var data = storage.get(); - - // XXX: should we even try to post data if it's larger than some reasonable - // threshold? - if (data && data.length !== 0) { - network.sendInteractionData(data, function() { - storage.clear(); - complete(oncomplete, true); - }, function(status) { - // if the server returns a 413 error, (too much data posted), then - // let's clear our local storage and move on. This does mean we - // loose some interaction data, but it shouldn't be statistically - // significant. - if (status && status.network && status.network.status === 413) { - storage.clear(); - } - complete(oncomplete, false); - }); - } - else { - complete(oncomplete, false); - } + var self=this; + + model.publishStaged(function(status) { + var msg = status ? "interaction_data_send_complete" : "interaction_data_send_error"; + self.publish(msg); + complete(oncomplete, status); + }); } function addEvent(eventName) { var self=this; - if (self.samplingEnabled === false) return; var eventData = [ eventName, new Date() - self.startTime ]; if (self.samplesBeingStored) { - var d = storage.current() || {}; + var d = model.getCurrent() || {}; if (!d.event_stream) d.event_stream = []; d.event_stream.push(eventData); - storage.setCurrent(d); + model.setCurrent(d); } else { self.initialEventStream.push(eventData); } } + function getCurrent() { + var self=this; + if(self.samplingEnabled === false) return; + + if (self.samplesBeingStored) { + return model.getCurrent(); + } + } + + function getCurrentEventStream() { + var self=this; + if(self.samplingEnabled === false) return; + + if (self.samplesBeingStored) { + return model.getCurrent().event_stream; + } + else { + return self.initialEventStream; + } + } + var Module = bid.Modules.PageModule.extend({ start: function(options) { options = options || {}; @@ -147,29 +149,28 @@ BrowserID.Modules.InteractionData = (function() { // a continuation, samplingEnabled will be decided on the first " // context_info" event, which corresponds to the first time // 'session_context' returns from the server. + // samplingEnabled flag ignored for a continuation. self.samplingEnabled = options.samplingEnabled; // continuation means the users dialog session is continuing, probably // due to a redirect to an IdP and then a return after authentication. if (options.continuation) { - var previousData = storage.current(); - - var samplingEnabled = self.samplingEnabled = !!previousData.event_stream; - if (samplingEnabled) { + // There will be no current data if the previous session was not + // allowed to save. + var previousData = model.getCurrent(); + if (previousData) { self.startTime = Date.parse(previousData.local_timestamp); - if (typeof self.samplingEnabled === "undefined") { - self.samplingEnabled = samplingEnabled; - } // instead of waiting for session_context to start appending data to // localStorage, start saving into localStorage now. - self.samplesBeingStored = true; + self.samplingEnabled = self.samplesBeingStored = true; } else { - // If there was no previous event stream, that means data collection + // If there was no previous data, that means data collection // was not allowed for the previous session. Return with no further // action, data collection is not allowed for this session either. + self.samplingEnabled = false; return; } } @@ -179,7 +180,7 @@ BrowserID.Modules.InteractionData = (function() { // The initialEventStream is used to store events until onSessionContext // is called. Once onSessionContext is called and it is known whether // the user's data will be saved, initialEventStream will either be - // discarded or added to the data set that is saved to localStorage. + // discarded or added to the data set that is saved to localmodel. self.initialEventStream = []; self.samplesBeingStored = false; @@ -194,16 +195,8 @@ BrowserID.Modules.InteractionData = (function() { }, addEvent: addEvent, - - getCurrentStoredData: function() { - var und; - return this.samplesBeingStored ? storage.current() : und; - }, - - getEventStream: function() { - return this.samplesBeingStored ? storage.current().event_stream : this.initialEventStream || []; - }, - + getCurrent: getCurrent, + getCurrentEventStream: getCurrentEventStream, publishStored: publishStored }); diff --git a/resources/static/shared/storage.js b/resources/static/shared/storage.js index 82ed0be6d3838d2d4e93695530005162997e38e6..1fa52161a9da6e0b65c7c0bad02e79dfe9682800 100644 --- a/resources/static/shared/storage.js +++ b/resources/static/shared/storage.js @@ -3,13 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /*globals BrowserID: true, console: true */ - -BrowserID.Storage = (function() { - "use strict"; - - var jwcrypto, - ONE_DAY_IN_MS = (1000 * 60 * 60 * 24), - storage; +BrowserID.getStorage = function() { + var storage; try { storage = localStorage; @@ -27,6 +22,16 @@ BrowserID.Storage = (function() { }; } + return storage; +}; + +BrowserID.Storage = (function() { + "use strict"; + + var jwcrypto, + ONE_DAY_IN_MS = (1000 * 60 * 60 * 24), + storage = BrowserID.getStorage(); + // temporary, replace with helpers.log if storage uses elog long term... function elog (msg) { if (window.console && console.error) console.error(msg); @@ -401,56 +406,6 @@ BrowserID.Storage = (function() { storage.emailToUserID = JSON.stringify(allInfo); } - function pushInteractionData(data) { - var id; - try { - id = JSON.parse(storage.interactionData); - id.unshift(data); - } catch(e) { - id = [ data ]; - } - storage.interactionData = JSON.stringify(id); - } - - function currentInteractionData() { - try { - return storage.interactionData ? JSON.parse(storage.interactionData)[0] : {}; - } catch(e) { - elog(e); - return {}; - } - } - - function setCurrentInteractionData(data) { - var id; - try { - id = JSON.parse(storage.interactionData); - id[0] = data; - } catch(e) { - elog(e); - id = [ data ]; - } - storage.interactionData = JSON.stringify(id); - } - - function getAllInteractionData() { - try { - return storage.interactionData ? JSON.parse(storage.interactionData) : []; - } catch(e) { - if (window.console && console.error) console.error(e); - return []; - } - } - - function clearInteractionData() { - try { - storage.interactionData = JSON.stringify([]); - } catch(e) { - storage.removeItem("interactionData"); - elog(e); - } - } - return { /** * Add an email address and optional key pair. @@ -533,44 +488,6 @@ BrowserID.Storage = (function() { remove: generic2KeyRemove.curry("main_site", "signInEmail") }, - interactionData: { - /** - * add a new interaction blob to localstorage, this will *push* any stored - * blobs to the 'completed' backlog, and happens when a new dialog interaction - * begins. - * @param {object} data - an object to push onto the queue - * @method interactionData.push() - * @returns nada - */ - push: pushInteractionData, - /** - * read the interaction data blob associated with the current interaction - * @method interactionData.current() - * @returns a JSON object containing the latest interaction data blob - */ - current: currentInteractionData, - /** - * overwrite the interaction data blob associated with the current interaction - * @param {object} data - the object to overwrite current with - * @method interactionData.setCurrent() - */ - setCurrent: setCurrentInteractionData, - /** - * get all past saved interaction data (returned as a JSON array), excluding - * the "current" data (that which is being collected now). - * @method interactionData.get() - * @returns an array, possibly of length zero if no past interaction data is - * available - */ - get: getAllInteractionData, - /** - * clear all interaction data, except the current, in-progress - * collection. - * @method interactionData.clear() - */ - clear: clearInteractionData - }, - usersComputer: { /** * Query whether the user has confirmed that this is their computer diff --git a/resources/static/test/cases/pages/verify_secondary_address.js b/resources/static/test/cases/pages/verify_secondary_address.js index a770237db60734b31e39a6d9f82553bb917b9bc5..d7eacaa574bb8d489c7a5897d0913c2060d43a6b 100644 --- a/resources/static/test/cases/pages/verify_secondary_address.js +++ b/resources/static/test/cases/pages/verify_secondary_address.js @@ -116,6 +116,7 @@ xhr.useResult("mustAuth"); createController(config, function() { xhr.useResult("valid"); + testHasClass("body", "enter_password"); controller.submit(function(status) { equal(status, true, "correct status"); testHasClass("body", "complete"); @@ -134,4 +135,88 @@ }); }); + asyncTest("must set password, successful login", function() { + xhr.useResult("needsPassword"); + createController(config, function() { + xhr.useResult("valid"); + + $("#password").val("password"); + $("#vpassword").val("password"); + + testHasClass("body", "enter_password"); + testHasClass("body", "enter_verify_password"); + + controller.submit(function(status) { + equal(status, true, "correct status"); + testHasClass("body", "complete"); + start(); + }); + }); + }); + + asyncTest("must set password, too short a password", function() { + xhr.useResult("needsPassword"); + createController(config, function() { + xhr.useResult("valid"); + + $("#password").val("pass"); + $("#vpassword").val("pass"); + + controller.submit(function(status) { + equal(status, false, "correct status"); + testHelpers.testTooltipVisible(); + start(); + }); + }); + }); + + asyncTest("must set password, too long a password", function() { + xhr.useResult("needsPassword"); + createController(config, function() { + xhr.useResult("valid"); + + var pass = testHelpers.generateString(81); + $("#password").val(pass); + $("#vpassword").val(pass); + + controller.submit(function(status) { + equal(status, false, "correct status"); + testHelpers.testTooltipVisible(); + start(); + }); + }); + }); + + asyncTest("must set password, missing verification password", function() { + xhr.useResult("needsPassword"); + createController(config, function() { + xhr.useResult("valid"); + + $("#password").val("password"); + $("#vpassword").val(""); + + controller.submit(function(status) { + equal(status, false, "correct status"); + testHelpers.testTooltipVisible(); + start(); + }); + }); + }); + + asyncTest("must set password, mismatched passwords", function() { + xhr.useResult("needsPassword"); + createController(config, function() { + xhr.useResult("valid"); + + $("#password").val("password"); + $("#vpassword").val("password1"); + + controller.submit(function(status) { + equal(status, false, "correct status"); + testHelpers.testTooltipVisible(); + start(); + }); + }); + }); + }()); diff --git a/resources/static/test/cases/shared/models/interaction_data.js b/resources/static/test/cases/shared/models/interaction_data.js new file mode 100644 index 0000000000000000000000000000000000000000..5454ccfc6e88f1e0a8cd1e250e132521d50192ff --- /dev/null +++ b/resources/static/test/cases/shared/models/interaction_data.js @@ -0,0 +1,108 @@ + +/*jshint browsers:true, forin: true, laxbreak: true */ +/*global test: true, start: true, stop: true, module: true, ok: true, equal: true, BrowserID:true */ +/* 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/. */ +(function() { + var bid = BrowserID, + model = bid.Models.InteractionData, + testHelpers = bid.TestHelpers, + testObjectValuesEqual = testHelpers.testObjectValuesEqual, + xhr = bid.Mocks.xhr; + + module("shared/models/interaction_data", { + setup: function() { + testHelpers.setup(); + localStorage.removeItem("interaction_data"); + }, + + teardown: function() { + testHelpers.teardown(); + } + }); + + test("after push, most recently pushed data available through getCurrent, getStaged gets previous data sets", function() { + model.push({ foo: "bar" }); + equal(model.getCurrent().foo, "bar", + "after pushing new interaction data, it's returned from .getCurrent()"); + + equal(model.getStaged().length, 0, "no data is yet staged"); + + model.push({ foo: "baz" }); + + equal(model.getCurrent().foo, "baz", "current points to new data set") + var staged = model.getStaged(); + + equal(staged.length, 1, "only one staged item"); + testObjectValuesEqual(staged[0], { foo: "bar" }); + }); + + test("setCurrent data overwrites current", function() { + model.clearStaged(); + model.push({ foo: "bar" }); + model.setCurrent({ foo: "baz" }); + equal(model.getCurrent().foo, "baz", + "overwriting current interaction data works"); + }); + + test("clearStaged clears staged interaction data but leaves current data unaffected", function() { + model.push({ foo: "bar" }); + model.push({ foo: "baz" }); + model.clearStaged(); + equal(model.getStaged().length, 0, + "after clearStageding, interaction data is zero length"); + equal(model.getCurrent().foo, "baz", + "after clearStageding, current data is unaffected"); + }); + + test("stageCurrent - stage the current data, if any. no data is current afterwards", function() { + // There is no current data to stage. + model.stageCurrent(); + equal(model.getStaged().length, 0, "no data to staged"); + + model.push({ foo: "bar" }); + model.stageCurrent(); + + equal(model.getStaged().length, 1, "current data staged"); + equal(typeof model.getCurrent(), "undefined", "current data removed after being staged"); + }); + + asyncTest("publishStored - publish any staged data", function() { + // There is no currently staged data. + model.publishStaged(function(status) { + equal(status, false, "no data currently staged"); + + // Simulate a throttling + // desired result - data is purged from staging table + + // The first pushed data will become staged. + model.push({ foo: "bar" }); + model.stageCurrent(); + + xhr.useResult("throttle"); + model.publishStaged(function(status) { + equal(false, status, "data throttling returns false status"); + // the previously staged data should we wiped on a throttling response. + + // When the interaction_data next completes, this will be the only data + // that is pushed. + model.push({ foo: "baz" }); + model.stageCurrent(); + + xhr.useResult("valid"); + model.publishStaged(function(status) { + equal(true, status, "data successfully posted"); + var request = xhr.getLastRequest('/wsapi/interaction_data'), + previousSessionsData = JSON.parse(request.data).data; + + equal(previousSessionsData.length, 1, "sending correct result sets"); + equal(previousSessionsData[0].foo, "baz", "correct data sent"); + start(); + }); + }); + + }); + + }); +}()); diff --git a/resources/static/test/cases/shared/modules/interaction_data.js b/resources/static/test/cases/shared/modules/interaction_data.js index 951c506903538a898a6e0c80c8fa8913aeff3593..c3daf5632d88f963af41f9332389fb3a8f710986 100644 --- a/resources/static/test/cases/shared/modules/interaction_data.js +++ b/resources/static/test/cases/shared/modules/interaction_data.js @@ -9,11 +9,16 @@ var bid = BrowserID, testHelpers = bid.TestHelpers, network = bid.Network, - storage = bid.Storage, + model = bid.Models.InteractionData, + xhr = bid.Mocks.xhr, + mediator = bid.Mediator, controller; module("shared/modules/interaction_data", { - setup: testHelpers.setup, + setup: function() { + testHelpers.setup(); + localStorage.removeItem("interaction_data"); + }, teardown: function() { testHelpers.teardown(); @@ -36,25 +41,35 @@ } asyncTest("samplingEnabled - ensure data collection working as expected", function() { + // Desired sequence: + // 1. When session_context completes, initialize this session's interaction + // data, sends previous session's data. + // 2. when network.sendInteractionData completes, previous session's data is + // erased, current session's data is unaffected. + + // simulate data stored for last session + model.push({ timestamp: new Date().getTime() }); + createController(); controller.addEvent("before_session_context"); - var events = controller.getEventStream(); + var events = controller.getCurrentEventStream(); ok(indexOfEvent(events, "before_session_context") > -1, "before_session_context correctly saved to event stream"); - ok(indexOfEvent(events, "after_session_context") === -1, "after_session_context not yet added to current event stream"); - // with context initializes the current stored data. - network.withContext(function() { - var data = controller.getCurrentStoredData(); + // Add an XHR delay to simulate interaction_data completeing after + // session_context completes. + xhr.setDelay(5); + + mediator.subscribe("interaction_data_send_complete", function() { + var data = controller.getCurrent(); // Make sure expected items are in the current stored data. testHelpers.testKeysInObject(data, ["event_stream", "sample_rate", "timestamp", "lang"]); controller.addEvent("after_session_context"); - var events = controller.getEventStream(); - + events = controller.getCurrentEventStream(); // Make sure both the before_session_context and after_session_context // are both on the event stream. ok(indexOfEvent(events, "before_session_context") > -1, "before_session_context correctly saved to current event stream"); @@ -73,28 +88,7 @@ start(); }); - }); - - asyncTest("publish data", function() { - createController(); - - // force saved data to be cleared. - storage.interactionData.clear(); - controller.publishStored(function(status) { - equal(status, false, "no data to publish"); - - // session context is required start saving events to localStorage. - network.withContext(function() { - - // Add an event which should allow us to publish - controller.addEvent("something_special"); - controller.publishStored(function(status) { - equal(status, true, "data correctly published"); - - start(); - }); - }); - }); + network.withContext(); }); asyncTest("samplingEnabled set to false - no data collection occurs", function() { @@ -104,12 +98,9 @@ // no stored data. network.withContext(function() { controller.addEvent("after_session_context"); - var events = controller.getEventStream(); - - var index = indexOfEvent(events, "after_session_context"); - equal(index, -1, "events not being stored"); - equal(typeof controller.getCurrentStoredData(), "undefined", "no stored data"); + equal(typeof controller.getCurrent(), "undefined", "no stored data"); + equal(typeof controller.getCurrentEventStream(), "undefined", "no data stored"); controller.publishStored(function(status) { equal(status, false, "there was no data to publish"); @@ -135,7 +126,7 @@ network.withContext(function() { controller.addEvent("session2_after_session_context"); - var events = controller.getEventStream(); + var events = controller.getCurrentEventStream(); ok(indexOfEvent(events, "session1_before_session_context") > -1, "session1_before_session_context correctly saved to current event stream"); ok(indexOfEvent(events, "session1_after_session_context") > -1, "session1_after_session_context correctly saved to current event stream"); @@ -166,12 +157,8 @@ network.withContext(function() { controller.addEvent("session2_after_session_context"); - var events = controller.getEventStream(); - - ok(indexOfEvent(events, "session1_before_session_context") === -1, "no data collected"); - ok(indexOfEvent(events, "session1_after_session_context") === -1, "no data collected"); - ok(indexOfEvent(events, "session2_before_session_context") === -1, "no data collected"); - ok(indexOfEvent(events, "session2_after_session_context") === -1, "no data collected"); + equal(typeof controller.getCurrent(), "undefined", "no data collected"); + equal(typeof controller.getCurrentEventStream(), "undefined", "no data collected"); controller.publishStored(function(status) { equal(status, false, "there was no data to publish"); @@ -179,8 +166,43 @@ }); }); }); - }); + asyncTest("simulate failed starts - data not sent until second successful session_context", function() { + // simulate three dialogs being opened. + // The first open dialog does not complete session_context, so data is + // never collected/sent for this session. + // The second has session_context complete, it starts collecting data which + // is sent when the third dialog has its session_context complete. + // The third has session_context complete and sends data for the second + // dialog opening. + + + // First open dialog never has session_context complete. Data is not + // collected. + createController(); + controller.addEvent("session1_before_session_context"); + + // Second open dialog is the first to successfully complete + // session_context, data should be collected. + createController(); + controller.addEvent("session2_before_session_context"); + network.withContext(function() { + + // Third open dialog successfully completes session_context, should send + // data for the 2nd open dialog once session_context completes. + createController(); + controller.addEvent("session2_before_session_context"); + + network.withContext(function() { + var request = xhr.getLastRequest('/wsapi/interaction_data'), + previousSessionsData = JSON.parse(request.data).data; + + equal(previousSessionsData.length, 1, "sending correct result sets"); + start(); + }); + }); + }); + }()); diff --git a/resources/static/test/cases/shared/storage.js b/resources/static/test/cases/shared/storage.js index b9b230b5e362aacd4174960d173810eabea5faa7..c9406c84a21ef077b4a150f956a3666a0eccc505 100644 --- a/resources/static/test/cases/shared/storage.js +++ b/resources/static/test/cases/shared/storage.js @@ -179,37 +179,5 @@ equal(typeof storage.signInEmail.get(), "undefined", "after remove, signInEmail is empty"); }); - test("push interaction data and get current", function() { - storage.interactionData.push({ foo: "bar" }); - equal(storage.interactionData.current().foo, "bar", - "after pushing new interaction data, it's returned from .current()"); - }); - - test("set interaction data overwrites current", function() { - storage.interactionData.clear(); - storage.interactionData.push({ foo: "bar" }); - storage.interactionData.setCurrent({ foo: "baz" }); - equal(storage.interactionData.current().foo, "baz", - "overwriting current interaction data works"); - equal(storage.interactionData.get().length, 1, - "overwriting doesn't append"); - }); - - test("clear interaction data", function() { - storage.interactionData.push({ foo: "bar" }); - storage.interactionData.push({ foo: "bar" }); - storage.interactionData.clear(); - equal(storage.interactionData.get().length, 0, - "after clearing, interaction data is zero length"); - }); - - test("get interaction data returns all data", function() { - storage.interactionData.push({ foo: "old2" }); - storage.interactionData.clear(); - storage.interactionData.push({ foo: "old1" }); - var d = storage.interactionData.get(); - equal(d.length, 1, "get() returns complete unpublished data blobs"); - equal(d[0].foo, 'old1', "get() returns complete unpublished data blobs"); - }); }()); diff --git a/resources/static/test/mocks/xhr.js b/resources/static/test/mocks/xhr.js index 8b505f997d4680903d922e8de6e2a90d78f08cc1..fc30baca72a584bef9a3ebc2470550ebdc073edf 100644 --- a/resources/static/test/mocks/xhr.js +++ b/resources/static/test/mocks/xhr.js @@ -21,19 +21,25 @@ BrowserID.Mocks.xhr = (function() { var random_cert = "eyJhbGciOiJSUzEyOCJ9.eyJpc3MiOiJpc3N1ZXIuY29tIiwiZXhwIjoxMzE2Njk1MzY3NzA3LCJwdWJsaWMta2V5Ijp7ImFsZ29yaXRobSI6IlJTIiwibiI6IjU2MDYzMDI4MDcwNDMyOTgyMzIyMDg3NDE4MTc2ODc2NzQ4MDcyMDM1NDgyODk4MzM0ODExMzY4NDA4NTI1NTk2MTk4MjUyNTE5MjY3MTA4MTMyNjA0MTk4MDA0NzkyODQ5MDc3ODY4OTUxOTA2MTcwODEyNTQwNzEzOTgyOTU0NjUzODEwNTM5OTQ5Mzg0NzEyNzczMzkwMjAwNzkxOTQ5NTY1OTAzNDM5NTIxNDI0OTA5NTc2ODMyNDE4ODkwODE5MjA0MzU0NzI5MjE3MjA3MzYwMTA1OTA2MDM5MDIzMjk5NTYxMzc0MDk4OTQyNzg5OTk2NzgwMTAyMDczMDcxNzYwODUyODQxMDY4OTg5ODYwNDAzNDMxNzM3NDgwMTgyNzI1ODUzODk5NzMzNzA2MDY5IiwiZSI6IjY1NTM3In0sInByaW5jaXBhbCI6eyJlbWFpbCI6InRlc3R1c2VyQHRlc3R1c2VyLmNvbSJ9fQ.aVIO470S_DkcaddQgFUXciGwq2F_MTdYOJtVnEYShni7I6mqBwK3fkdWShPEgLFWUSlVUtcy61FkDnq2G-6ikSx1fUZY7iBeSCOKYlh6Kj9v43JX-uhctRSB2pI17g09EUtvmb845EHUJuoowdBLmLa4DSTdZE-h4xUQ9MsY7Ik"; /** - * This is the results table, the keys are the request type, url, and + * This is the responses table, the keys are the request type, url, and * a "selector" for testing. The right is the expected return value, already * decoded. If a result is "undefined", the request's error handler will be * called. */ var xhr = { - results: { + // Keep track of the last request made to each wsapi call. keyed only on + // url - for instince - instead of "get /wsapi/session_context + // valid", the key would only be "/wsapi/session_context" + requests: {}, + + responses: { "get /wsapi/session_context valid": contextInfo, // We are going to test for XHR failures for session_context using // the flag contextAjaxError. "get /wsapi/session_context contextAjaxError": undefined, "get /wsapi/email_for_token?token=token valid": { email: "testuser@testuser.com" }, "get /wsapi/email_for_token?token=token mustAuth": { email: "testuser@testuser.com", must_auth: true }, + "get /wsapi/email_for_token?token=token needsPassword": { email: "testuser@testuser.com", needs_password: true }, "get /wsapi/email_for_token?token=token invalid": { success: false }, "post /wsapi/authenticate_user valid": { success: true, userid: 1 }, "post /wsapi/authenticate_user invalid": { success: false }, @@ -85,7 +91,6 @@ BrowserID.Mocks.xhr = (function() { "post /wsapi/stage_email invalid": { success: false }, "post /wsapi/stage_email throttle": 429, "post /wsapi/stage_email ajaxError": undefined, - "post /wsapi/cert_key ajaxError": undefined, "get /wsapi/email_addition_status?email=testuser%40testuser.com complete": { status: "complete" }, "get /wsapi/email_addition_status?email=registered%40testuser.com pending": { status: "pending" }, "get /wsapi/email_addition_status?email=registered%40testuser.com complete": { status: "complete" }, @@ -124,6 +129,7 @@ BrowserID.Mocks.xhr = (function() { "post /wsapi/prolong_session unauthenticated": 400, "post /wsapi/prolong_session ajaxError": undefined, "post /wsapi/interaction_data valid": { success: true }, + "post /wsapi/interaction_data throttle": 413, "post /wsapi/interaction_data ajaxError": undefined }, @@ -136,61 +142,63 @@ BrowserID.Mocks.xhr = (function() { }, useResult: function(result) { - xhr.resultType = result; + xhr.responseName = result; }, - getLastRequest: function() { - return this.req; + getLastRequest: function(key) { + var req = this.request; + if (key) { + req = this.requests[key]; + } + + return req; }, - ajax: function(obj) { + ajax: function(request) { //console.log("ajax request"); - var type = obj.type ? obj.type.toLowerCase() : "get"; - - var req = this.req = { - type: type, - url: obj.url, - data: obj.data - }; + var type = request.type ? request.type.toLowerCase() : "get"; + this.request = request = _.extend(request, { + type: type + }); - if(type === "post" && obj.data.indexOf("csrf") === -1) { + if (type === "post" && request.data.indexOf("csrf") === -1) { ok(false, "missing csrf token on POST request"); } - - var resultType = xhr.resultType; + var responseName = xhr.responseName; // Unless the contextAjaxError is specified, use the "valid" context info. // This makes it so we do not have to keep adding new items for // context_info for every possible result type. - if(req.url === "/wsapi/session_context" && resultType !== "contextAjaxError") { - resultType = "valid"; + if (request.url === "/wsapi/session_context" && responseName !== "contextAjaxError") { + responseName = "valid"; } - var resName = req.type + " " + req.url + " " + resultType; + var responseKey = request.type + " " + request.url + " " + responseName, + response = xhr.responses[responseKey], + typeofResponse = typeof response; - var result = xhr.results[resName]; + this.requests[request.url] = request; - var type = typeof result; - if(type === "function") { - result(obj.success); + if (typeofResponse === "function") { + response(request.success); } - else if(!(type == "number" || type == "undefined")) { - if(obj.success) { - if(delay) { + else if (!(typeofResponse == "number" || typeofResponse == "undefined")) { + if (request.success) { + if (delay) { // simulate response delay - _.delay(obj.success, delay, result); + _.delay(request.success, delay, response); } else { - obj.success(result); + request.success(response); } } } - else if (obj.error) { - // Invalid result - either invalid URL, invalid GET/POST or - // invalid resultType - obj.error({ status: result || 400, responseText: "response text" }, "errorStatus", "errorThrown"); + else if (request.error) { + // Invalid response - either invalid URL, invalid GET/POST or + // invalid responseName + request.error({ status: response || 400, responseText: "response text" }, "errorStatus", "errorThrown"); } } }; diff --git a/resources/static/test/testHelpers/helpers.js b/resources/static/test/testHelpers/helpers.js index 287ba8393c039d0519f9c892fcc79495f62d051f..5f72fae9ff39fc56b9b95de99c2b142962d89816 100644 --- a/resources/static/test/testHelpers/helpers.js +++ b/resources/static/test/testHelpers/helpers.js @@ -181,7 +181,7 @@ BrowserID.TestHelpers = (function() { start(); }); - if(transport.resultType === "valid") { + if(transport.responseName === "valid") { transport.useResult("ajaxError"); } @@ -200,6 +200,11 @@ BrowserID.TestHelpers = (function() { }, testKeysInObject: function(objToTest, expected, msg) { + if (!objToTest) { + ok(false, "Missing object to test against"); + return; + } + for(var i=0, key; key=expected[i]; ++i) { ok(key in objToTest, msg || ("object contains " + key)); } diff --git a/resources/views/add_email_address.ejs b/resources/views/add_email_address.ejs index fa6fbd891b20f14c23532b9497da17adf3127e16..45b711cec7d8d7b53a9da83b7f2c1f933de87ca4 100644 --- a/resources/views/add_email_address.ejs +++ b/resources/views/add_email_address.ejs @@ -31,6 +31,20 @@ <%= gettext('Password must be between 8 and 80 characters long.') %> </div> </li> + + <li class="password_entry" id="verify_password"> + <label class="serif" for="vpassword"><%= gettext('Verify Password') %></label> + <input class="sans" id="vpassword" placeholder="<%= gettext('Repeat Password') %>" type="password" maxlength="80"> + + <div id="vpassword_required" class="tooltip" for="vpassword"> + <%= gettext('Verification password is required.') %> + </div> + + <div class="tooltip" id="passwords_no_match" for="vpassword"> + <%= gettext ('Passwords do not match.') %> + </div> + + </li> </ul> <div class="submit cf password_entry"> diff --git a/resources/views/test.ejs b/resources/views/test.ejs index 30c4faf532c2d3c034eb70b9fe8a96402c0a3e40..5b1890c5630a9ea5cb134fe3c722dbf4c1e44fff 100644 --- a/resources/views/test.ejs +++ b/resources/views/test.ejs @@ -104,6 +104,9 @@ <script src="/shared/history.js"></script> <script src="/shared/state_machine.js"></script> + <script src="/shared/models/models.js"></script> + <script src="/shared/models/interaction_data.js"></script> + <script src="/shared/modules/page_module.js"></script> <script src="/shared/modules/xhr_delay.js"></script> <script src="/shared/modules/xhr_disable_form.js"></script> @@ -155,6 +158,8 @@ <script src="cases/shared/history.js"></script> <script src="cases/shared/state_machine.js"></script> + <script src="cases/shared/models/interaction_data.js"></script> + <script src="cases/shared/modules/page_module.js"></script> <script src="cases/shared/modules/xhr_delay.js"></script> <script src="cases/shared/modules/xhr_disable_form.js"></script> diff --git a/resources/views/verify_email_address.ejs b/resources/views/verify_email_address.ejs index a73c80ead95230830d13bc8f218e5e84e878c314..1f275d3fe8c3362f98b6360c58484d401e2a9e9c 100644 --- a/resources/views/verify_email_address.ejs +++ b/resources/views/verify_email_address.ejs @@ -18,6 +18,7 @@ <label class="serif" for="email"><%= gettext('Email Address') %></label> <input class="youraddress sans" id="email" placeholder="<%= gettext('Your Email') %>" type="email" value="" disabled="disabled" maxlength="254" /> </li> + <li class="password_entry"> <label class="serif" for="password"><%= gettext('Password') %></label> <input class="sans" id="password" placeholder="<%= gettext('Your Password') %>" type="password" autofocus maxlength=80 /> @@ -30,6 +31,20 @@ <%= gettext('Password must be between 8 and 80 characters long.') %> </div> </li> + + <li class="password_entry" id="verify_password"> + <label class="serif" for="vpassword"><%= gettext('Verify Password') %></label> + <input class="sans" id="vpassword" placeholder="<%= gettext('Repeat Password') %>" type="password" maxlength="80"> + + <div id="vpassword_required" class="tooltip" for="vpassword"> + <%= gettext('Verification password is required.') %> + </div> + + <div class="tooltip" id="passwords_no_match" for="vpassword"> + <%= gettext ('Passwords do not match.') %> + </div> + + </li> </ul> <div class="submit cf password_entry">