diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js index fcc3b4d92..5a9d1bde2 100644 --- a/app/assets/javascripts/app/services/authManager.js +++ b/app/assets/javascripts/app/services/authManager.js @@ -73,13 +73,12 @@ angular.module('app.frontend') } Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){ - var mk = keys.mk; var requestUrl = url + "/auth/sign_in"; var request = Restangular.oneUrl(requestUrl, requestUrl); var params = {password: keys.pw, email: email}; _.merge(request, params); request.post().then(function(response){ - this.handleAuthResponse(response, email, url, authParams, mk, keys.pw); + this.handleAuthResponse(response, email, url, authParams, keys.mk, keys.pw); callback(response); }.bind(this)) .catch(function(response){ @@ -91,7 +90,9 @@ angular.module('app.frontend') } this.handleAuthResponse = function(response, email, url, authParams, mk, pw) { - localStorage.setItem("server", url); + if(url) { + localStorage.setItem("server", url); + } localStorage.setItem("user", JSON.stringify(response.plain().user)); localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"]))); localStorage.setItem("mk", mk); @@ -101,13 +102,12 @@ angular.module('app.frontend') this.register = function(url, email, password, callback) { Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){ - var mk = keys.mk; var requestUrl = url + "/auth"; var request = Restangular.oneUrl(requestUrl, requestUrl); var params = _.merge({password: keys.pw, email: email}, authParams); _.merge(request, params); request.post().then(function(response){ - this.handleAuthResponse(response, email, url, authParams, mk, keys.pw); + this.handleAuthResponse(response, email, url, authParams, keys.mk, keys.pw); callback(response); }.bind(this)) .catch(function(response){ @@ -117,55 +117,26 @@ angular.module('app.frontend') }.bind(this)); } - // this.changePassword = function(current_password, new_password) { - // this.getAuthParamsForEmail(email, function(authParams){ - // if(!authParams) { - // callback(null); - // return; - // } - // Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: current_password, email: user.email}, authParams), function(currentKeys) { - // Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: new_password, email: user.email}, authParams), function(newKeys){ - // var data = {}; - // data.current_password = currentKeys.pw; - // data.password = newKeys.pw; - // data.password_confirmation = newKeys.pw; - // - // var user = this.user; - // - // this._performPasswordChange(currentKeys, newKeys, function(response){ - // if(response && !response.error) { - // // this.showNewPasswordForm = false; - // // reencrypt data with new mk - // this.reencryptAllItemsAndSave(user, newKeys.mk, currentKeys.mk, function(success){ - // if(success) { - // this.setMk(newKeys.mk); - // alert("Your password has been changed and your data re-encrypted."); - // } else { - // // rollback password - // this._performPasswordChange(newKeys, currentKeys, function(response){ - // alert("There was an error changing your password. Your password has been rolled back."); - // window.location.reload(); - // }) - // } - // }.bind(this)); - // } else { - // // this.showNewPasswordForm = false; - // alert("There was an error changing your password. Please try again."); - // } - // }.bind(this)) - // }.bind(this)); - // }.bind(this)); - // }.bind(this)); - // } + this.changePassword = function(email, new_password, callback) { + Neeto.crypto.generateInitialEncryptionKeysForUser({password: new_password, email: email}, function(keys, authParams){ + var requestUrl = localStorage.getItem("server") + "/auth/change_pw"; + var request = Restangular.oneUrl(requestUrl, requestUrl); + var params = _.merge({new_password: keys.pw}, authParams); + _.merge(request, params); - this._performPasswordChange = function(url, email, current_keys, new_keys, callback) { - var requestUrl = url + "/auth"; - var request = Restangular.oneUrl(requestUrl, requestUrl); - var params = {password: new_keys.pw, password_confirmation: new_keys.pw, current_password: current_keys.pw, email: email}; - _.merge(request, params); - request.patch().then(function(response){ - callback(response); - }) + request.post().then(function(response){ + this.handleAuthResponse(response, email, null, authParams, keys.mk, keys.pw); + callback(response.plain()); + }.bind(this)) + .catch(function(response){ + var error = response.data; + if(!error) { + error = {message: "Something went wrong while changing your password. Your password was not changed. Please try again."} + } + console.log("Change pw error", response); + callback({error: error}); + }) + }.bind(this)); } this.staticifyObject = function(object) { diff --git a/app/assets/javascripts/app/services/directives/functional/delay-hide.js b/app/assets/javascripts/app/services/directives/functional/delay-hide.js index 7467208f4..6e381ed3f 100644 --- a/app/assets/javascripts/app/services/directives/functional/delay-hide.js +++ b/app/assets/javascripts/app/services/directives/functional/delay-hide.js @@ -20,11 +20,15 @@ angular }); function showSpinner() { + if(scope.hidePromise) { + $timeout.cancel(scope.hidePromise); + scope.hidePromise = null; + } showElement(true); } function hideSpinner() { - $timeout(showElement.bind(this, false), getDelay()); + scope.hidePromise = $timeout(showElement.bind(this, false), getDelay()); } function showElement(show) { diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js index 27c491280..088b98969 100644 --- a/app/assets/javascripts/app/services/directives/views/accountMenu.js +++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js @@ -15,10 +15,6 @@ class AccountMenu { $scope.syncStatus = syncManager.syncStatus; - $scope.changePasswordPressed = function() { - $scope.showNewPasswordForm = !$scope.showNewPasswordForm; - } - $scope.encryptionKey = function() { return syncManager.masterKey; } @@ -31,20 +27,57 @@ class AccountMenu { return `${$scope.server}/dashboard/?server=${$scope.server}&id=${$scope.user.email}&pw=${$scope.serverPassword()}`; } + $scope.newPasswordData = {}; + + $scope.showPasswordChangeForm = function() { + $scope.newPasswordData.showForm = true; + } + $scope.submitPasswordChange = function() { - $scope.passwordChangeData.status = "Generating New Keys..."; - $timeout(function(){ - if(data.password != data.password_confirmation) { - alert("Your new password does not match its confirmation."); - return; - } + if($scope.newPasswordData.newPassword != $scope.newPasswordData.newPasswordConfirmation) { + alert("Your new password does not match its confirmation."); + $scope.newPasswordData.status = null; + return; + } - authManager.changePassword($scope.passwordChangeData.current_password, $scope.passwordChangeData.new_password, function(response){ + var email = $scope.user.email; + if(!email) { + alert("We don't have your email stored. Please log out then log back in to fix this issue."); + $scope.newPasswordData.status = null; + return; + } + $scope.newPasswordData.status = "Generating New Keys..."; + $scope.newPasswordData.showForm = false; + + // perform a sync beforehand to pull in any last minutes changes before we change the encryption key (and thus cant decrypt new changes) + syncManager.sync(function(response){ + authManager.changePassword(email, $scope.newPasswordData.newPassword, function(response){ + if(response.error) { + alert("There was an error changing your password. Please try again."); + $scope.newPasswordData.status = null; + return; + } + + // re-encrypt all items + $scope.newPasswordData.status = "Re-encrypting all items with your new key..."; + + modelManager.setAllItemsDirty(); + syncManager.sync(function(response){ + if(response.error) { + alert("There was an error re-encrypting your items. Your password was changed, but not all your items were properly re-encrypted and synced. You should try syncing again. If all else fails, you should restore your notes from backup.") + return; + } + $scope.newPasswordData.status = "Successfully changed password and re-encrypted all items."; + $timeout(function(){ + alert("Your password has been changed, and your items successfully re-encrypted and synced. You must sign out of all other signed in applications and sign in again, or else you may corrupt your data.") + $scope.newPasswordData = {}; + }, 1000) + }); }) - }) + } $scope.loginSubmitPressed = function() { @@ -116,6 +149,8 @@ class AccountMenu { $scope.importData = null; if(!response) { alert("There was an error importing your data. Please try again."); + } else { + alert("Your data was successfully imported.") } }) }) @@ -149,8 +184,6 @@ class AccountMenu { } $scope.importJSONData = function(data, password, callback) { - console.log("Importing data", data); - var onDataReady = function() { var items = modelManager.mapResponseItemsToLocalModels(data.items); items.forEach(function(item){ diff --git a/app/assets/javascripts/app/services/helpers/encryptionHelper.js b/app/assets/javascripts/app/services/helpers/encryptionHelper.js index 7cf011ac8..e0e171af5 100644 --- a/app/assets/javascripts/app/services/helpers/encryptionHelper.js +++ b/app/assets/javascripts/app/services/helpers/encryptionHelper.js @@ -1,14 +1,8 @@ class EncryptionHelper { static encryptItem(item, key) { - var item_key = null; - if(item.enc_item_key) { - // we reuse the key, but this is optional - item_key = Neeto.crypto.decryptText(item.enc_item_key, key); - } else { - item_key = Neeto.crypto.generateRandomEncryptionKey(); - item.enc_item_key = Neeto.crypto.encryptText(item_key, key); - } + var item_key = Neeto.crypto.generateRandomEncryptionKey(); + item.enc_item_key = Neeto.crypto.encryptText(item_key, key); var ek = Neeto.crypto.firstHalfOfKey(item_key); var ak = Neeto.crypto.secondHalfOfKey(item_key); diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index aa436b6e2..8b23060dc 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -8,6 +8,7 @@ class ModelManager { this.itemChangeObservers = []; this.items = []; this._extensions = []; + this.acceptableContentTypes = ["Note", "Tag", "Extension"]; } get allItems() { @@ -62,7 +63,7 @@ class ModelManager { for (var json_obj of items) { json_obj = _.omit(json_obj, omitFields || []) var item = this.findItem(json_obj["uuid"]); - if(json_obj["deleted"] == true) { + if(json_obj["deleted"] == true || !_.includes(this.acceptableContentTypes, json_obj["content_type"])) { if(item) { this.removeItemLocally(item) } @@ -225,6 +226,17 @@ class ModelManager { item.removeAllRelationships(); } + /* Used when changing encryption key */ + setAllItemsDirty() { + var relevantItems = this.allItems.filter(function(item){ + return _.includes(this.acceptableContentTypes, item.content_type); + }.bind(this)); + + for(var item of relevantItems) { + item.setDirty(true); + } + } + removeItemLocally(item, callback) { _.pull(this.items, item); diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js index d2d4b2fd3..ddabf436d 100644 --- a/app/assets/javascripts/app/services/syncManager.js +++ b/app/assets/javascripts/app/services/syncManager.js @@ -98,10 +98,37 @@ class SyncManager { return this._cursorToken; } + get queuedCallbacks() { + if(!this._queuedCallbacks) { + this._queuedCallbacks = []; + } + return this._queuedCallbacks; + } + + clearQueuedCallbacks() { + this._queuedCallbacks = []; + } + + callQueuedCallbacksAndCurrent(currentCallback, response) { + var allCallbacks = this.queuedCallbacks; + if(currentCallback) { + allCallbacks.push(currentCallback); + } + if(allCallbacks.length) { + for(var eachCallback of allCallbacks) { + eachCallback(response); + } + this.clearQueuedCallbacks(); + } + } + sync(callback, options = {}) { if(this.syncStatus.syncOpInProgress) { this.repeatOnCompletion = true; + if(callback) { + this.queuedCallbacks.push(callback); + } console.log("Sync op in progress; returning."); return; } @@ -116,18 +143,17 @@ class SyncManager { return; } - var isContinuationSync = this.needsMoreSync; + var isContinuationSync = this.syncStatus.needsMoreSync; - this.repeatOnCompletion = false; this.syncStatus.syncOpInProgress = true; let submitLimit = 100; var subItems = allDirtyItems.slice(0, submitLimit); if(subItems.length < allDirtyItems.length) { // more items left to be synced, repeat - this.needsMoreSync = true; + this.syncStatus.needsMoreSync = true; } else { - this.needsMoreSync = false; + this.syncStatus.needsMoreSync = false; } if(!isContinuationSync) { @@ -153,10 +179,10 @@ class SyncManager { this.$rootScope.$broadcast("sync:updated_token", this.syncToken); var retrieved = this.handleItemsResponse(response.retrieved_items, null); + // merge only metadata for saved items - // Update 2/9/17: I just realized we may not need to handle saved_items anymore. We used to do this because we wanted to merge presentation-related metadata, - // but that has since been removed. Since this function is an important part of the functioning of the app, I'm not going to remove it just yet without careful - // testing. + // we write saved items to disk now because it clears their dirty status then saves + // if we saved items before completion, we had have to save them as dirty and save them again on success as clean var omitFields = ["content", "auth_hash"]; var saved = this.handleItemsResponse(response.saved_items, omitFields); @@ -172,14 +198,17 @@ class SyncManager { this.syncToken = response.sync_token; this.cursorToken = response.cursor_token; - if(this.cursorToken || this.repeatOnCompletion || this.needsMoreSync) { + if(this.cursorToken || this.syncStatus.needsMoreSync) { + setTimeout(function () { + this.sync(callback, options); + }.bind(this), 10); // wait 10ms to allow UI to update + } else if(this.repeatOnCompletion) { + this.repeatOnCompletion = false; setTimeout(function () { this.sync(callback, options); }.bind(this), 10); // wait 10ms to allow UI to update } else { - if(callback) { - callback(response); - } + this.callQueuedCallbacksAndCurrent(callback, response); } }.bind(this)) @@ -193,9 +222,7 @@ class SyncManager { this.$rootScope.$broadcast("sync:error", error); - if(callback) { - callback({error: "Sync error"}); - } + this.callQueuedCallbacksAndCurrent(callback, {error: "Sync error"}); }.bind(this)) } @@ -214,7 +241,7 @@ class SyncManager { var item = this.modelManager.findItem(itemResponse.uuid); var error = mapping.error; if(error.tag == "uuid_conflict") { - // uuid conflicts can occur if a user attempts to import an old data archive with uuids form the old account into a new account + // uuid conflicts can occur if a user attempts to import an old data archive with uuids from the old account into a new account this.modelManager.alternateUUIDForItem(item, handleNext); } ++i; diff --git a/app/assets/stylesheets/app/_header.scss b/app/assets/stylesheets/app/_header.scss index 74efb241a..5ba6cab79 100644 --- a/app/assets/stylesheets/app/_header.scss +++ b/app/assets/stylesheets/app/_header.scss @@ -124,6 +124,10 @@ font-weight: bold !important; } +.italic { + font-style: italic !important; +} + .normal { font-weight: normal !important; } diff --git a/app/assets/templates/frontend/directives/account-menu.html.haml b/app/assets/templates/frontend/directives/account-menu.html.haml index 62ffe0db9..6927bff0f 100644 --- a/app/assets/templates/frontend/directives/account-menu.html.haml +++ b/app/assets/templates/frontend/directives/account-menu.html.haml @@ -28,6 +28,13 @@ %div{"ng-if" => "user"} %h2 {{user.email}} %p {{server}} + %div.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress || syncStatus.needsMoreSync", "delay" => "1000"} + .spinner.inline.mr-5.blue + {{"Syncing" + (syncStatus.total > 0 ? ":" : "")}} + %span{"ng-if" => "syncStatus.total > 0"} {{syncStatus.current}}/{{syncStatus.total}} + %p.bold.mt-10.red.block{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}} + + %a.block.mt-15{"href" => "{{dashboardURL()}}", "target" => "_blank"} → Standard File Dashboard %a.block.mt-5{"ng-click" => "showCredentials = !showCredentials"} Show Credentials %section.gray-bg.mt-10.medium-padding{"ng-if" => "showCredentials"} %label.block @@ -36,13 +43,23 @@ %label.block.mt-5.mb-0 Server password: .wrap.normal.mt-1 {{serverPassword() ? serverPassword() : 'Not available. Sign out then sign back in to compute.'}} - %a.block.mt-5{"href" => "{{dashboardURL()}}", "target" => "_blank"} Standard File Dashboard - %div.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress", "delay" => "1000"} - .spinner.inline.mr-5.blue - Syncing - %span{"ng-if" => "syncStatus.total > 0"}: {{syncStatus.current}}/{{syncStatus.total}} - %p.bold.mt-10.red.block{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}} + %a.block.mt-5{"ng-click" => "newPasswordData.changePassword = !newPasswordData.changePassword"} Change Password + %section.gray-bg.mt-10.medium-padding{"ng-if" => "newPasswordData.changePassword"} + %p.bold Change Password (Beta) + %p.mt-10 Since your encryption key is based on your password, changing your password requires all your notes and tags to be re-encrypted using your new key. + %p.mt-5 If you have thousands of items, this can take several minutes — you must keep the application window open during this process. + %p.mt-5 After changing your password, you must log out of all other applications currently signed in to your account. + %p.bold.mt-5 It is highly recommended you download a backup of your data before proceeding. + %div.mt-10{"ng-if" => "!newPasswordData.status"} + %a.red.mr-5{"ng-if" => "!newPasswordData.showForm", "ng-click" => "showPasswordChangeForm()"} Continue + %a{"ng-click" => "newPasswordData.changePassword = false; newPasswordData.showForm = false"} Cancel + %div.mt-10{"ng-if" => "newPasswordData.showForm"} + %form + %input.form-control{"type" => "text", "ng-model" => "newPasswordData.newPassword", "placeholder" => "Enter new password"} + %input.form-control{"type" => "text", "ng-model" => "newPasswordData.newPasswordConfirmation", "placeholder" => "Confirm new password"} + %button.btn.dark-button.btn-block{"ng-click" => "submitPasswordChange()"} Submit + %p.italic.mt-10{"ng-if" => "newPasswordData.status"} {{newPasswordData.status}} .medium-v-space