diff --git a/app/assets/javascripts/app/app.frontend.js b/app/assets/javascripts/app/app.frontend.js index f19dfe4fd..da2051625 100644 --- a/app/assets/javascripts/app/app.frontend.js +++ b/app/assets/javascripts/app/app.frontend.js @@ -1,6 +1,7 @@ 'use strict'; var Neeto = Neeto || {}; +var SN = SN || {}; // detect IE8 and above, and edge. // IE and Edge do not support pbkdf2 in WebCrypto, therefore we need to use CryptoJS @@ -17,12 +18,9 @@ angular.module('app.frontend', [ 'restangular' ]) -.config(function (RestangularProvider, apiControllerProvider) { +.config(function (RestangularProvider, authManagerProvider) { RestangularProvider.setDefaultHeaders({"Content-Type": "application/json"}); - var url = apiControllerProvider.defaultServerURL(); - RestangularProvider.setBaseUrl(url + "/api"); - RestangularProvider.setFullRequestInterceptor(function(element, operation, route, url, headers, params, httpConfig) { var token = localStorage.getItem("jwt"); if(token) { diff --git a/app/assets/javascripts/app/frontend/controllers/_base.js b/app/assets/javascripts/app/frontend/controllers/_base.js index e6e22df8d..a220696a4 100644 --- a/app/assets/javascripts/app/frontend/controllers/_base.js +++ b/app/assets/javascripts/app/frontend/controllers/_base.js @@ -1,9 +1,9 @@ class BaseCtrl { - constructor($rootScope, modelManager, apiController, dbManager) { + constructor(syncManager, dbManager) { dbManager.openDatabase(null, function(){ // new database, delete syncToken so that items can be refetched entirely from server - apiController.clearSyncToken(); - apiController.sync(); + syncManager.clearSyncToken(); + syncManager.sync(); }) } } diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js index a12a75394..c0c8e2909 100644 --- a/app/assets/javascripts/app/frontend/controllers/editor.js +++ b/app/assets/javascripts/app/frontend/controllers/editor.js @@ -19,9 +19,9 @@ angular.module('app.frontend') /** * Insert 4 spaces when a tab key is pressed, * only used when inside of the text editor. - * If the shift key is pressed first, this event is - * not fired. - */ + * If the shift key is pressed first, this event is + * not fired. + */ var handleTab = function (event) { if (!event.shiftKey && event.which == 9) { event.preventDefault(); @@ -29,13 +29,13 @@ angular.module('app.frontend') var end = this.selectionEnd; var spaces = " "; - // Insert 4 spaces + // Insert 4 spaces this.value = this.value.substring(0, start) + spaces + this.value.substring(end); - // Place cursor 4 spaces away from where - // the tab key was pressed - this.selectionStart = this.selectionEnd = start + 4; + // Place cursor 4 spaces away from where + // the tab key was pressed + this.selectionStart = this.selectionEnd = start + 4; } } @@ -88,7 +88,7 @@ angular.module('app.frontend') } } }) - .controller('EditorCtrl', function ($sce, $timeout, apiController, markdownRenderer, $rootScope, extensionManager) { + .controller('EditorCtrl', function ($sce, $timeout, authManager, markdownRenderer, $rootScope, extensionManager, syncManager) { this.setNote = function(note, oldNote) { this.editorMode = 'edit'; @@ -146,16 +146,20 @@ angular.module('app.frontend') note.dummy = false; this.save()(note, function(success){ if(success) { - apiController.clearDraft(); - if(statusTimeout) $timeout.cancel(statusTimeout); statusTimeout = $timeout(function(){ - this.noteStatus = "All changes saved" + var status = "All changes saved" + if(authManager.offline()) { + status += " (offline)"; + } + this.saveError = false; + this.noteStatus = status; }.bind(this), 200) } else { if(statusTimeout) $timeout.cancel(statusTimeout); statusTimeout = $timeout(function(){ - this.noteStatus = "(Offline) — All changes saved" + this.saveError = true; + this.noteStatus = "Error saving" }.bind(this), 200) } }.bind(this)); @@ -171,10 +175,6 @@ angular.module('app.frontend') this.changesMade = function() { this.note.hasChanges = true; this.note.dummy = false; - if(apiController.isUserSignedIn()) { - // signed out users have local autosave, dont need draft saving - apiController.saveDraftToDisk(this.note); - } if(saveTimeout) $timeout.cancel(saveTimeout); if(statusTimeout) $timeout.cancel(statusTimeout); @@ -236,9 +236,10 @@ angular.module('app.frontend') } this.deleteNote = function() { - apiController.clearDraft(); - this.remove()(this.note); - this.showMenu = false; + if(confirm("Are you sure you want to delete this note?")) { + this.remove()(this.note); + this.showMenu = false; + } } this.clickedEditNote = function() { diff --git a/app/assets/javascripts/app/frontend/controllers/header.js b/app/assets/javascripts/app/frontend/controllers/header.js index 61ed0f955..624d3b391 100644 --- a/app/assets/javascripts/app/frontend/controllers/header.js +++ b/app/assets/javascripts/app/frontend/controllers/header.js @@ -1,5 +1,5 @@ angular.module('app.frontend') - .directive("header", function(apiController, extensionManager){ + .directive("header", function(authManager){ return { restrict: 'E', scope: {}, @@ -12,119 +12,54 @@ angular.module('app.frontend') link:function(scope, elem, attrs, ctrl) { scope.$on("sync:updated_token", function(){ ctrl.syncUpdated(); + ctrl.findErrors(); + ctrl.updateOfflineStatus(); + }) + scope.$on("sync:error", function(){ + ctrl.findErrors(); + ctrl.updateOfflineStatus(); }) } } }) - .controller('HeaderCtrl', function ($state, apiController, modelManager, $timeout, extensionManager, dbManager) { + .controller('HeaderCtrl', function (authManager, modelManager, $timeout, dbManager, syncManager) { - this.user = apiController.user; - this.extensionManager = extensionManager; - this.loginData = {mergeLocal: true}; + this.user = authManager.user; - this.changePasswordPressed = function() { - this.showNewPasswordForm = !this.showNewPasswordForm; + this.updateOfflineStatus = function() { + this.offline = authManager.offline(); } + this.updateOfflineStatus(); + + this.findErrors = function() { + this.error = syncManager.syncStatus.error; + } + this.findErrors(); this.accountMenuPressed = function() { - this.serverData = {url: apiController.getServer()}; + this.serverData = {}; this.showAccountMenu = !this.showAccountMenu; this.showFaq = false; this.showNewPasswordForm = false; this.showExtensionsMenu = false; + this.showIOMenu = false; } this.toggleExtensions = function() { this.showAccountMenu = false; + this.showIOMenu = false; this.showExtensionsMenu = !this.showExtensionsMenu; } - this.toggleExtensionForm = function() { - this.newExtensionData = {}; - this.showNewExtensionForm = !this.showNewExtensionForm; - } - - this.submitNewExtensionForm = function() { - if(this.newExtensionData.url) { - extensionManager.addExtension(this.newExtensionData.url, function(response){ - if(!response) { - alert("Unable to register this extension. Make sure the link is valid and try again."); - } else { - this.newExtensionData.url = ""; - this.showNewExtensionForm = false; - } - }.bind(this)) - } - } - - this.selectedAction = function(action, extension) { - action.running = true; - extensionManager.executeAction(action, extension, null, function(response){ - action.running = false; - if(response && response.error) { - action.error = true; - alert("There was an error performing this action. Please try again."); - } else { - action.error = false; - apiController.sync(null); - } - }) - } - - this.deleteExtension = function(extension) { - if(confirm("Are you sure you want to delete this extension?")) { - extensionManager.deleteExtension(extension); - } - } - - this.reloadExtensionsPressed = function() { - if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) { - extensionManager.refreshExtensionsFromServer(); - } - } - - this.changeServer = function() { - apiController.setServer(this.serverData.url, true); - } - - this.signOutPressed = function() { + this.toggleIO = function() { + this.showIOMenu = !this.showIOMenu; + this.showExtensionsMenu = false; this.showAccountMenu = false; - apiController.signout(function(){ - window.location.reload(); - }) - } - - this.submitPasswordChange = function() { - this.passwordChangeData.status = "Generating New Keys..."; - - $timeout(function(){ - if(data.password != data.password_confirmation) { - alert("Your new password does not match its confirmation."); - return; - } - - apiController.changePassword(this.passwordChangeData.current_password, this.passwordChangeData.new_password, function(response){ - - }) - - }.bind(this)) - } - - this.localNotesCount = function() { - return modelManager.filteredNotes.length; - } - - this.mergeLocalChanged = function() { - if(!this.loginData.mergeLocal) { - if(!confirm("Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?")) { - this.loginData.mergeLocal = true; - } - } } this.refreshData = function() { this.isRefreshing = true; - apiController.sync(function(response){ + syncManager.sync(function(response){ $timeout(function(){ this.isRefreshing = false; }.bind(this), 200) @@ -139,110 +74,4 @@ angular.module('app.frontend') this.syncUpdated = function() { this.lastSyncDate = new Date(); } - - this.loginSubmitPressed = function() { - this.loginData.status = "Generating Login Keys..."; - - $timeout(function(){ - apiController.login(this.loginData.email, this.loginData.user_password, function(response){ - if(!response || response.error) { - var error = response ? response.error : {message: "An unknown error occured."} - this.loginData.status = null; - if(!response || (response && !response.didDisplayAlert)) { - alert(error.message); - } - } else { - this.onAuthSuccess(response.user); - } - }.bind(this)); - }.bind(this)) - } - - this.submitRegistrationForm = function() { - this.loginData.status = "Generating Account Keys..."; - - $timeout(function(){ - apiController.register(this.loginData.email, this.loginData.user_password, function(response){ - if(!response || response.error) { - var error = response ? response.error : {message: "An unknown error occured."} - this.loginData.status = null; - alert(error.message); - } else { - this.onAuthSuccess(response.user); - } - }.bind(this)); - }.bind(this)) - } - - this.encryptionStatusForNotes = function() { - var allNotes = modelManager.filteredNotes; - return allNotes.length + "/" + allNotes.length + " notes encrypted"; - } - - this.archiveEncryptionFormat = {encrypted: true}; - - this.downloadDataArchive = function() { - var link = document.createElement('a'); - link.setAttribute('download', 'notes.json'); - link.href = apiController.itemsDataFile(this.archiveEncryptionFormat.encrypted); - link.click(); - } - - this.performImport = function(data, password) { - this.importData.loading = true; - // allow loading indicator to come up with timeout - $timeout(function(){ - apiController.importJSONData(data, password, function(success, response){ - console.log("Import response:", success, response); - this.importData.loading = false; - if(success) { - this.importData = null; - } else { - alert("There was an error importing your data. Please try again."); - } - }.bind(this)) - }.bind(this)) - } - - this.submitImportPassword = function() { - this.performImport(this.importData.data, this.importData.password); - } - - this.importFileSelected = function(files) { - this.importData = {}; - - var file = files[0]; - var reader = new FileReader(); - reader.onload = function(e) { - var data = JSON.parse(e.target.result); - $timeout(function(){ - if(data.auth_params) { - // request password - this.importData.requestPassword = true; - this.importData.data = data; - } else { - this.performImport(data, null); - } - }.bind(this)) - }.bind(this) - - reader.readAsText(file); - } - - this.onAuthSuccess = function(user) { - var block = function(){ - window.location.reload(); - this.showLogin = false; - this.showRegistration = false; - }.bind(this); - - if(!this.loginData.mergeLocal) { - dbManager.clearAllItems(function(){ - block(); - }); - } else { - block(); - } - } - - }); +}); diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js index b03d22838..8d1ebcb03 100644 --- a/app/assets/javascripts/app/frontend/controllers/home.js +++ b/app/assets/javascripts/app/frontend/controllers/home.js @@ -1,15 +1,15 @@ angular.module('app.frontend') -.controller('HomeCtrl', function ($scope, $rootScope, $timeout, apiController, modelManager) { +.controller('HomeCtrl', function ($scope, $rootScope, $timeout, modelManager, syncManager, authManager) { $rootScope.bodyClass = "app-body-class"; - apiController.loadLocalItems(function(items){ + syncManager.loadLocalItems(function(items) { $scope.$apply(); - apiController.sync(null); + syncManager.sync(null); // refresh every 30s - setInterval(function () { - apiController.sync(null); - }, 30000); + // setInterval(function () { + // syncManager.sync(null); + // }, 30000); }); $scope.allTag = new Tag({all: true}); @@ -31,7 +31,7 @@ angular.module('app.frontend') modelManager.createRelationshipBetweenItems(note, tag); } - apiController.sync(); + syncManager.sync(); } /* @@ -56,8 +56,12 @@ angular.module('app.frontend') } $scope.tagsSave = function(tag, callback) { + if(!tag.title || tag.title.length == 0) { + $scope.notesRemoveTag(tag); + return; + } tag.setDirty(true); - apiController.sync(callback); + syncManager.sync(callback); } /* @@ -69,12 +73,9 @@ angular.module('app.frontend') if(validNotes == 0) { modelManager.setItemToBeDeleted(tag); // if no more notes, delete tag - apiController.sync(function(){ + syncManager.sync(function(){ // force scope tags to update on sub directives - $scope.tags = []; - $timeout(function(){ - $scope.tags = modelManager.tags; - }) + $scope.safeApply(); }); } else { alert("To delete this tag, remove all its notes first."); @@ -100,7 +101,7 @@ angular.module('app.frontend') $scope.saveNote = function(note, callback) { note.setDirty(true); - apiController.sync(function(response){ + syncManager.sync(function(response){ if(response && response.error) { if(!$scope.didShowErrorAlert) { $scope.didShowErrorAlert = true; @@ -137,8 +138,8 @@ angular.module('app.frontend') return; } - apiController.sync(function(){ - if(!apiController.user) { + syncManager.sync(function(){ + if(authManager.offline()) { // when deleting items while ofline, we need to explictly tell angular to refresh UI setTimeout(function () { $scope.safeApply(); diff --git a/app/assets/javascripts/app/frontend/controllers/notes.js b/app/assets/javascripts/app/frontend/controllers/notes.js index 111eb95d8..5bb7fba5a 100644 --- a/app/assets/javascripts/app/frontend/controllers/notes.js +++ b/app/assets/javascripts/app/frontend/controllers/notes.js @@ -24,7 +24,7 @@ angular.module('app.frontend') } } }) - .controller('NotesCtrl', function (apiController, $timeout, $rootScope, modelManager) { + .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager) { $rootScope.$on("editorFocused", function(){ this.showMenu = false; @@ -32,6 +32,11 @@ angular.module('app.frontend') var isFirstLoad = true; + this.notesToDisplay = 20; + this.paginate = function() { + this.notesToDisplay += 20 + } + this.tagDidChange = function(tag, oldTag) { this.showMenu = false; @@ -48,14 +53,8 @@ angular.module('app.frontend') if(isFirstLoad) { $timeout(function(){ - var draft = apiController.getDraft(); - if(draft) { - var note = draft; - this.selectNote(note); - } else { - this.createNewNote(); - isFirstLoad = false; - } + this.createNewNote(); + isFirstLoad = false; }.bind(this)) } else if(tag.notes.length == 0) { this.createNewNote(); diff --git a/app/assets/javascripts/app/frontend/controllers/tags.js b/app/assets/javascripts/app/frontend/controllers/tags.js index 5f673c2da..422c308f2 100644 --- a/app/assets/javascripts/app/frontend/controllers/tags.js +++ b/app/assets/javascripts/app/frontend/controllers/tags.js @@ -79,19 +79,20 @@ angular.module('app.frontend') this.saveTag = function($event, tag) { this.editingTag = null; - if(tag.title.length == 0) { - tag.title = originalTagName; - originalTagName = ""; + $event.target.blur(); + + if(!tag.title || tag.title.length == 0) { + if(originalTagName) { + tag.title = originalTagName; + originalTagName = null; + } else { + // newly created tag without content + modelManager.removeItemLocally(tag); + } return; } - $event.target.blur(); - if(!tag.title || tag.title.length == 0) { - return; - } - this.save()(tag, function(savedTag){ - // _.merge(tag, savedTag); this.selectTag(tag); this.newTag = null; }.bind(this)); diff --git a/app/assets/javascripts/app/frontend/models/api/item.js b/app/assets/javascripts/app/frontend/models/api/item.js index b87e66c8d..f6129f5a0 100644 --- a/app/assets/javascripts/app/frontend/models/api/item.js +++ b/app/assets/javascripts/app/frontend/models/api/item.js @@ -50,10 +50,6 @@ class Item { } } - alternateUUID() { - this.uuid = Neeto.crypto.generateUUID(); - } - setDirty(dirty) { this.dirty = dirty; @@ -107,6 +103,10 @@ class Item { // must override } + isBeingRemovedLocally() { + + } + removeAllRelationships() { // must override this.setDirty(true); diff --git a/app/assets/javascripts/app/frontend/models/app/extension.js b/app/assets/javascripts/app/frontend/models/app/extension.js index acd42b4e2..d6a46aad2 100644 --- a/app/assets/javascripts/app/frontend/models/app/extension.js +++ b/app/assets/javascripts/app/frontend/models/app/extension.js @@ -1,18 +1,20 @@ class Action { constructor(json) { - _.merge(this, json); - this.running = false; // in case running=true was synced with server since model is uploaded nondiscriminatory - this.error = false; - if(this.lastExecuted) { - // is string - this.lastExecuted = new Date(this.lastExecuted); - } + _.merge(this, json); + this.running = false; // in case running=true was synced with server since model is uploaded nondiscriminatory + this.error = false; + if(this.lastExecuted) { + // is string + this.lastExecuted = new Date(this.lastExecuted); + } } - get permissionsString() { + permissionsString() { + console.log("permissions", this.permissions); if(!this.permissions) { return ""; } + var permission = this.permissions.charAt(0).toUpperCase() + this.permissions.slice(1); // capitalize first letter permission += ": "; for(var contentType of this.content_types) { @@ -28,7 +30,7 @@ class Action { return permission; } - get encryptionModeString() { + encryptionModeString() { if(this.verb != "post") { return null; } @@ -54,6 +56,12 @@ class Extension extends Item { this.encrypted = true; this.content_type = "Extension"; + + if(json.actions) { + this.actions = json.actions.map(function(action){ + return new Action(action); + }) + } } actionsInGlobalContext() { diff --git a/app/assets/javascripts/app/frontend/models/app/note.js b/app/assets/javascripts/app/frontend/models/app/note.js index 31d27cf80..b7b4a6c06 100644 --- a/app/assets/javascripts/app/frontend/models/app/note.js +++ b/app/assets/javascripts/app/frontend/models/app/note.js @@ -56,6 +56,13 @@ class Note extends Item { this.tags = []; } + isBeingRemovedLocally() { + this.tags.forEach(function(tag){ + _.pull(tag.notes, this); + }.bind(this)) + super.isBeingRemovedLocally(); + } + static filterDummyNotes(notes) { var filtered = notes.filter(function(note){return note.dummy == false || note.dummy == null}); return filtered; diff --git a/app/assets/javascripts/app/frontend/models/app/tag.js b/app/assets/javascripts/app/frontend/models/app/tag.js index f94c2eba4..f8361ca5a 100644 --- a/app/assets/javascripts/app/frontend/models/app/tag.js +++ b/app/assets/javascripts/app/frontend/models/app/tag.js @@ -55,6 +55,13 @@ class Tag extends Item { this.notes = []; } + isBeingRemovedLocally() { + this.notes.forEach(function(note){ + _.pull(note.tags, this); + }.bind(this)) + super.isBeingRemovedLocally(); + } + get content_type() { return "Tag"; } diff --git a/app/assets/javascripts/app/frontend/models/local/itemParams.js b/app/assets/javascripts/app/frontend/models/local/itemParams.js new file mode 100644 index 000000000..396a51bd1 --- /dev/null +++ b/app/assets/javascripts/app/frontend/models/local/itemParams.js @@ -0,0 +1,57 @@ +class ItemParams { + + constructor(item, ek) { + this.item = item; + this.ek = ek; + } + + paramsForExportFile() { + this.additionalFields = ["updated_at"]; + this.forExportFile = true; + return _.omit(this.__params(), ["deleted"]); + } + + paramsForExtension() { + return this.paramsForExportFile(); + } + + paramsForLocalStorage() { + this.additionalFields = ["updated_at", "dirty"]; + this.forExportFile = true; + return this.__params(); + } + + paramsForSync() { + return this.__params(null, false); + } + + __params() { + var itemCopy = _.cloneDeep(this.item); + + console.assert(!this.item.dummy, "Item is dummy, should not have gotten here.", this.item.dummy) + + var params = {uuid: this.item.uuid, content_type: this.item.content_type, deleted: this.item.deleted, created_at: this.item.created_at}; + + if(this.ek) { + EncryptionHelper.encryptItem(itemCopy, this.ek); + params.content = itemCopy.content; + params.enc_item_key = itemCopy.enc_item_key; + params.auth_hash = itemCopy.auth_hash; + } + else { + params.content = this.forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties())); + if(!this.forExportFile) { + params.enc_item_key = null; + params.auth_hash = null; + } + } + + if(this.additionalFields) { + _.merge(params, _.pick(this.item, this.additionalFields)); + } + + return params; + } + + +} diff --git a/app/assets/javascripts/app/services/apiController.js b/app/assets/javascripts/app/services/apiController.js deleted file mode 100644 index 7c4e163b1..000000000 --- a/app/assets/javascripts/app/services/apiController.js +++ /dev/null @@ -1,607 +0,0 @@ -angular.module('app.frontend') - .provider('apiController', function () { - - function domainName() { - var domain_comps = location.hostname.split("."); - var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1]; - return domain; - } - - var url; - - this.defaultServerURL = function() { - if(!url) { - url = localStorage.getItem("server"); - if(!url) { - url = "https://n3.standardnotes.org"; - } - } - return url; - } - - - this.$get = function($rootScope, Restangular, modelManager, dbManager) { - return new ApiController($rootScope, Restangular, modelManager, dbManager); - } - - function ApiController($rootScope, Restangular, modelManager, dbManager) { - - var userData = localStorage.getItem("user"); - if(userData) { - this.user = JSON.parse(userData); - } else { - // legacy, check for uuid - var idData = localStorage.getItem("uuid"); - if(idData) { - this.user = {uuid: idData}; - } - } - this.syncToken = localStorage.getItem("syncToken"); - - /* - Config - */ - - this.getServer = function() { - if(!url) { - url = localStorage.getItem("server"); - if(!url) { - url = "https://n3.standardnotes.org"; - this.setServer(url); - } - } - return url; - } - - this.setServer = function(url, refresh) { - localStorage.setItem("server", url); - if(refresh) { - window.location.reload(); - } - } - - - /* - Auth - */ - - this.getAuthParams = function() { - return JSON.parse(localStorage.getItem("auth_params")); - } - - this.isUserSignedIn = function() { - return localStorage.getItem("jwt"); - } - - this.getAuthParamsForEmail = function(email, callback) { - var request = Restangular.one("auth", "params"); - request.get({email: email}).then(function(response){ - callback(response.plain()); - }) - .catch(function(response){ - console.log("Error getting current user", response); - callback(response.data); - }) - } - - this.supportsPasswordDerivationCost = function(cost) { - // some passwords are created on platforms with stronger pbkdf2 capabilities, like iOS, - // which accidentally used 60,000 iterations (now adjusted), which CryptoJS can't handle here (WebCrypto can however). - // if user has high password cost and is using browser that doesn't support WebCrypto, - // we want to tell them that they can't login with this browser. - if(cost > 5000) { - return Neeto.crypto instanceof SNCryptoWeb ? true : false; - } else { - return true; - } - } - - this.login = function(email, password, callback) { - this.getAuthParamsForEmail(email, function(authParams){ - if(!authParams) { - callback(null); - return; - } - - if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) { - var string = "Your account was created on a platform with higher security capabilities than this browser supports. " + - "If we attempted to generate your login keys here, it would take hours. " + - "Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to login." - alert(string) - callback({didDisplayAlert: true}); - return; - } - - Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){ - this.setMk(keys.mk); - var request = Restangular.one("auth/sign_in"); - var params = {password: keys.pw, email: email}; - _.merge(request, params); - request.post().then(function(response){ - localStorage.setItem("jwt", response.token); - localStorage.setItem("user", JSON.stringify(response.user)); - localStorage.setItem("auth_params", JSON.stringify(authParams)); - callback(response); - }) - .catch(function(response){ - callback(response.data); - }) - }.bind(this)); - }.bind(this)) - } - - this.register = function(email, password, callback) { - Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){ - this.setMk(keys.mk); - keys.mk = null; - var request = Restangular.one("auth"); - var params = _.merge({password: keys.pw, email: email}, authParams); - _.merge(request, params); - request.post().then(function(response){ - localStorage.setItem("jwt", response.token); - localStorage.setItem("user", JSON.stringify(response.user)); - localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"]))); - callback(response); - }) - .catch(function(response){ - callback(response.data); - }) - }.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._performPasswordChange = function(email, current_keys, new_keys, callback) { - var request = Restangular.one("auth"); - 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); - }) - } - - - /* - Items - */ - - this.setSyncToken = function(syncToken) { - this.syncToken = syncToken; - localStorage.setItem("syncToken", this.syncToken); - } - - this.syncWithOptions = function(callback, options = {}) { - - if(this.syncOpInProgress) { - // will perform anoter sync after current completes - this.repeatSync = true; - return; - } - - this.syncOpInProgress = true; - - var allDirtyItems = modelManager.getDirtyItems(); - - // we want to write all dirty items to disk only if the user is not signed in, or if the sync op fails - // if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server - var writeAllDirtyItemsToDisk = function(completion) { - this.writeItemsToLocalStorage(allDirtyItems, function(responseItems){ - if(completion) { - completion(); - } - }) - }.bind(this); - - if(!this.isUserSignedIn()) { - writeAllDirtyItemsToDisk(function(){ - // delete anything needing to be deleted - allDirtyItems.forEach(function(item){ - if(item.deleted) { - modelManager.removeItemLocally(item); - } - }.bind(this)) - - modelManager.clearDirtyItems(allDirtyItems); - - }.bind(this)) - this.syncOpInProgress = false; - if(callback) { - callback(); - } - return; - } - - let submitLimit = 100; - var dirtyItems = allDirtyItems.slice(0, submitLimit); - if(dirtyItems.length < allDirtyItems.length) { - // more items left to be synced, repeat - this.repeatSync = true; - } else { - this.repeatSync = false; - } - - var request = Restangular.one("items/sync"); - request.limit = 150; - request.sync_token = this.syncToken; - request.cursor_token = this.cursorToken; - request.items = _.map(dirtyItems, function(item){ - return this.createRequestParamsForItem(item, options.additionalFields); - }.bind(this)); - - request.post().then(function(response) { - - modelManager.clearDirtyItems(dirtyItems); - - // handle sync token - this.setSyncToken(response.sync_token); - $rootScope.$broadcast("sync:updated_token", this.syncToken); - - // handle cursor token (more results waiting, perform another sync) - this.cursorToken = response.cursor_token; - - var retrieved = this.handleItemsResponse(response.retrieved_items, null); - // merge only metadata for saved items - var omitFields = ["content", "auth_hash"]; - var saved = this.handleItemsResponse(response.saved_items, omitFields); - - this.handleUnsavedItemsResponse(response.unsaved) - - this.writeItemsToLocalStorage(saved, null); - this.writeItemsToLocalStorage(retrieved, null); - - this.syncOpInProgress = false; - - if(this.cursorToken || this.repeatSync == true) { - this.syncWithOptions(callback, options); - } else { - if(callback) { - callback(response); - } - } - - }.bind(this)) - .catch(function(response){ - console.log("Sync error: ", response); - - writeAllDirtyItemsToDisk(); - this.syncOpInProgress = false; - - if(callback) { - callback({error: "Sync error"}); - } - }.bind(this)) - } - - this.sync = function(callback) { - this.syncWithOptions(callback, undefined); - } - - this.handleUnsavedItemsResponse = function(unsaved) { - if(unsaved.length == 0) { - return; - } - - console.log("Handle unsaved", unsaved); - for(var mapping of unsaved) { - var itemResponse = mapping.item; - var item = modelManager.findItem(itemResponse.uuid); - var error = mapping.error; - if(error.tag == "uuid_conflict") { - item.alternateUUID(); - item.setDirty(true); - item.markAllReferencesDirty(); - } - } - - this.syncWithOptions(null, {additionalFields: ["created_at", "updated_at"]}); - } - - this.handleItemsResponse = function(responseItems, omitFields) { - this.decryptItems(responseItems); - return modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields); - } - - this.createRequestParamsForItem = function(item, additionalFields) { - return this.paramsForItem(item, true, additionalFields, false); - } - - this.paramsForExportFile = function(item, encrypted) { - return _.omit(this.paramsForItem(item, encrypted, ["created_at", "updated_at"], true), ["deleted"]); - } - - this.paramsForExtension = function(item, encrypted) { - return _.omit(this.paramsForItem(item, encrypted, ["created_at", "updated_at"], true), ["deleted"]); - } - - this.paramsForItem = function(item, encrypted, additionalFields, forExportFile) { - var itemCopy = _.cloneDeep(item); - - console.assert(!item.dummy, "Item is dummy, should not have gotten here.", item.dummy) - - var params = {uuid: item.uuid, content_type: item.content_type, deleted: item.deleted}; - - if(encrypted) { - this.encryptSingleItem(itemCopy, this.retrieveMk()); - params.content = itemCopy.content; - params.enc_item_key = itemCopy.enc_item_key; - params.auth_hash = itemCopy.auth_hash; - } - else { - params.content = forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties())); - if(!forExportFile) { - params.enc_item_key = null; - params.auth_hash = null; - } - } - - if(additionalFields) { - _.merge(params, _.pick(item, additionalFields)); - } - - return params; - } - - /* - Import - */ - - this.clearSyncToken = function() { - this.syncToken = null; - localStorage.removeItem("syncToken"); - } - - this.importJSONData = function(data, password, callback) { - console.log("Importing data", data); - - var onDataReady = function() { - var items = modelManager.mapResponseItemsToLocalModels(data.items); - items.forEach(function(item){ - item.setDirty(true); - item.markAllReferencesDirty(); - }) - this.syncWithOptions(callback, {additionalFields: ["created_at", "updated_at"]}); - }.bind(this) - - if(data.auth_params) { - Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){ - var mk = keys.mk; - try { - this.decryptItemsWithKey(data.items, mk); - // delete items enc_item_key since the user's actually key will do the encrypting once its passed off - data.items.forEach(function(item){ - item.enc_item_key = null; - item.auth_hash = null; - }) - onDataReady(); - } - catch (e) { - console.log("Error decrypting", e); - alert("There was an error decrypting your items. Make sure the password you entered is correct and try again."); - callback(false, null); - return; - } - }.bind(this)); - } else { - onDataReady(); - } - } - - /* - Export - */ - - this.itemsDataFile = function(encrypted) { - var textFile = null; - var makeTextFile = function (text) { - var data = new Blob([text], {type: 'text/json'}); - - // If we are replacing a previously generated file we need to - // manually revoke the object URL to avoid memory leaks. - if (textFile !== null) { - window.URL.revokeObjectURL(textFile); - } - - textFile = window.URL.createObjectURL(data); - - // returns a URL you can use as a href - return textFile; - }.bind(this); - - var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){ - return this.paramsForExportFile(item, encrypted); - }.bind(this)); - - var data = { - items: items - } - - if(encrypted) { - data["auth_params"] = this.getAuthParams(); - } - - return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */)); - } - - this.staticifyObject = function(object) { - return JSON.parse(JSON.stringify(object)); - } - - this.writeItemsToLocalStorage = function(items, callback) { - var params = items.map(function(item) { - return this.paramsForItem(item, false, ["created_at", "updated_at", "dirty"], true) - }.bind(this)); - - dbManager.saveItems(params, callback); - } - - this.loadLocalItems = function(callback) { - var params = dbManager.getAllItems(function(items){ - var items = this.handleItemsResponse(items, null); - Item.sortItemsByDate(items); - callback(items); - }.bind(this)) - - } - - /* - Drafts - */ - - this.saveDraftToDisk = function(draft) { - localStorage.setItem("draft", JSON.stringify(draft)); - } - - this.clearDraft = function() { - localStorage.removeItem("draft"); - } - - this.getDraft = function() { - var draftString = localStorage.getItem("draft"); - if(!draftString || draftString == 'undefined') { - return null; - } - var jsonObj = _.merge({content_type: "Note"}, JSON.parse(draftString)); - return modelManager.createItem(jsonObj); - } - - - /* - Encrpytion - */ - - this.retrieveMk = function() { - if(!this.mk) { - this.mk = localStorage.getItem("mk"); - } - return this.mk; - } - - this.setMk = function(mk) { - localStorage.setItem('mk', mk); - } - - this.signout = function(callback) { - dbManager.clearAllItems(function(){ - localStorage.clear(); - callback(); - }); - } - - this.encryptSingleItem = function(item, masterKey) { - var item_key = null; - if(item.enc_item_key) { - item_key = Neeto.crypto.decryptText(item.enc_item_key, masterKey); - } else { - item_key = Neeto.crypto.generateRandomEncryptionKey(); - item.enc_item_key = Neeto.crypto.encryptText(item_key, masterKey); - } - - var ek = Neeto.crypto.firstHalfOfKey(item_key); - var ak = Neeto.crypto.secondHalfOfKey(item_key); - var encryptedContent = "001" + Neeto.crypto.encryptText(JSON.stringify(item.createContentJSONFromProperties()), ek); - var authHash = Neeto.crypto.hmac256(encryptedContent, ak); - - item.content = encryptedContent; - item.auth_hash = authHash; - item.local_encryption_scheme = "1.0"; - } - - this.decryptSingleItem = function(item, masterKey) { - var item_key = Neeto.crypto.decryptText(item.enc_item_key, masterKey); - - var ek = Neeto.crypto.firstHalfOfKey(item_key); - var ak = Neeto.crypto.secondHalfOfKey(item_key); - var authHash = Neeto.crypto.hmac256(item.content, ak); - if(authHash !== item.auth_hash || !item.auth_hash) { - console.log("Authentication hash does not match.") - return; - } - - var content = Neeto.crypto.decryptText(item.content.substring(3, item.content.length), ek); - item.content = content; - } - - this.decryptItems = function(items) { - var masterKey = this.retrieveMk(); - this.decryptItemsWithKey(items, masterKey); - } - - this.decryptItemsWithKey = function(items, key) { - for (var item of items) { - if(item.deleted == true) { - continue; - } - var isString = typeof item.content === 'string' || item.content instanceof String; - if(isString) { - try { - if(item.content.substring(0, 3) == "001" && item.enc_item_key) { - // is encrypted - this.decryptSingleItem(item, key); - } else { - // is base64 encoded - item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length)) - } - } catch (e) { - console.log("Error decrypting item", item, e); - continue; - } - } - } - } - - this.reencryptAllItemsAndSave = function(user, newMasterKey, oldMasterKey, callback) { - var items = modelManager.allItems(); - items.forEach(function(item){ - if(item.content.substring(0, 3) == "001" && item.enc_item_key) { - // first decrypt item_key with old key - var item_key = Neeto.crypto.decryptText(item.enc_item_key, oldMasterKey); - // now encrypt item_key with new key - item.enc_item_key = Neeto.crypto.encryptText(item_key, newMasterKey); - } - }); - - this.saveBatchItems(user, items, function(success) { - callback(success); - }.bind(this)); - } - } -}); diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js new file mode 100644 index 000000000..cd936b929 --- /dev/null +++ b/app/assets/javascripts/app/services/authManager.js @@ -0,0 +1,178 @@ +angular.module('app.frontend') + .provider('authManager', function () { + + function domainName() { + var domain_comps = location.hostname.split("."); + var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1]; + return domain; + } + + this.$get = function($rootScope, Restangular, modelManager) { + return new AuthManager($rootScope, Restangular, modelManager); + } + + function AuthManager($rootScope, Restangular, modelManager) { + + var userData = localStorage.getItem("user"); + if(userData) { + this.user = JSON.parse(userData); + } else { + // legacy, check for uuid + var idData = localStorage.getItem("uuid"); + if(idData) { + this.user = {uuid: idData}; + } + } + + this.offline = function() { + return !this.user; + } + + this.getAuthParams = function() { + return JSON.parse(localStorage.getItem("auth_params")); + } + + this.getAuthParamsForEmail = function(url, email, callback) { + var requestUrl = url + "/auth/params"; + var request = Restangular.oneUrl(requestUrl, requestUrl); + request.get({email: email}).then(function(response){ + callback(response.plain()); + }) + .catch(function(response){ + console.log("Error getting auth params", response); + callback(null); + }) + } + + this.supportsPasswordDerivationCost = function(cost) { + // some passwords are created on platforms with stronger pbkdf2 capabilities, like iOS, + // which accidentally used 60,000 iterations (now adjusted), which CryptoJS can't handle here (WebCrypto can however). + // if user has high password cost and is using browser that doesn't support WebCrypto, + // we want to tell them that they can't login with this browser. + if(cost > 5000) { + return Neeto.crypto instanceof SNCryptoWeb ? true : false; + } else { + return true; + } + } + + this.login = function(url, email, password, callback) { + this.getAuthParamsForEmail(url, email, function(authParams){ + if(!authParams) { + callback({error : {message: "Unable to get authentication parameters."}}); + return; + } + + if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) { + var string = "Your account was created on a platform with higher security capabilities than this browser supports. " + + "If we attempted to generate your login keys here, it would take hours. " + + "Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to login." + alert(string) + callback({didDisplayAlert: true}); + return; + } + + console.log("compute encryption keys", password, authParams); + + 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); + callback(response); + }.bind(this)) + .catch(function(response){ + console.log("Error logging in", response); + callback(response.data); + }) + }.bind(this)); + }.bind(this)) + } + + this.handleAuthResponse = function(response, email, url, authParams, mk, pw) { + 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); + localStorage.setItem("pw", pw); + localStorage.setItem("jwt", response.token); + } + + 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); + callback(response); + }.bind(this)) + .catch(function(response){ + console.log("Registration error", response); + callback(null); + }) + }.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._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); + }) + } + + this.staticifyObject = function(object) { + return JSON.parse(JSON.stringify(object)); + } + + } +}); diff --git a/app/assets/javascripts/app/services/dbManager.js b/app/assets/javascripts/app/services/dbManager.js index 0d702b032..d7e3edabb 100644 --- a/app/assets/javascripts/app/services/dbManager.js +++ b/app/assets/javascripts/app/services/dbManager.js @@ -98,11 +98,14 @@ class DBManager { }, null) } - deleteItem(item) { + deleteItem(item, callback) { this.openDatabase((db) => { var request = db.transaction("items", "readwrite").objectStore("items").delete(item.uuid); request.onsuccess = function(event) { console.log("Successfully deleted item", item.uuid); + if(callback) { + callback(true); + } }; }, null) } diff --git a/app/assets/javascripts/app/services/directives/autofocus.js b/app/assets/javascripts/app/services/directives/functional/autofocus.js similarity index 100% rename from app/assets/javascripts/app/services/directives/autofocus.js rename to app/assets/javascripts/app/services/directives/functional/autofocus.js diff --git a/app/assets/javascripts/app/services/directives/functional/delay-hide.js b/app/assets/javascripts/app/services/directives/functional/delay-hide.js new file mode 100644 index 000000000..7467208f4 --- /dev/null +++ b/app/assets/javascripts/app/services/directives/functional/delay-hide.js @@ -0,0 +1,42 @@ +angular + .module('app.frontend') + .directive('delayHide', function($timeout) { + return { + restrict: 'A', + scope: { + show: '=', + delay: '@' + }, + link: function(scope, elem, attrs) { + var showTimer; + + showElement(false); + + //This is where all the magic happens! + // Whenever the scope variable updates we simply + // show if it evaluates to 'true' and hide if 'false' + scope.$watch('show', function(newVal){ + newVal ? showSpinner() : hideSpinner(); + }); + + function showSpinner() { + showElement(true); + } + + function hideSpinner() { + $timeout(showElement.bind(this, false), getDelay()); + } + + function showElement(show) { + show ? elem.css({display:''}) : elem.css({display:'none'}); + } + + function getDelay() { + var delay = parseInt(scope.delay); + + return angular.isNumber(delay) ? delay : 200; + } + } + + }; +}); diff --git a/app/assets/javascripts/app/services/directives/file-change.js b/app/assets/javascripts/app/services/directives/functional/file-change.js similarity index 100% rename from app/assets/javascripts/app/services/directives/file-change.js rename to app/assets/javascripts/app/services/directives/functional/file-change.js diff --git a/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js b/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js new file mode 100644 index 000000000..a9f565db3 --- /dev/null +++ b/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js @@ -0,0 +1,19 @@ +angular.module('app.frontend').directive('infiniteScroll', [ +'$rootScope', '$window', '$timeout', function($rootScope, $window, $timeout) { + return { + link: function(scope, elem, attrs) { + elem.css('overflow-x', 'hidden'); + elem.css('height', 'inherit'); + + var offset = parseInt(attrs.threshold) || 0; + var e = elem[0] + + elem.on('scroll', function(){ + if(scope.$eval(attrs.canLoad) && e.scrollTop + e.offsetHeight >= e.scrollHeight - offset) { + scope.$apply(attrs.infiniteScroll); + } + }); + } + }; +} +]); diff --git a/app/assets/javascripts/app/services/directives/lowercase.js b/app/assets/javascripts/app/services/directives/functional/lowercase.js similarity index 100% rename from app/assets/javascripts/app/services/directives/lowercase.js rename to app/assets/javascripts/app/services/directives/functional/lowercase.js diff --git a/app/assets/javascripts/app/services/directives/selectOnClick.js b/app/assets/javascripts/app/services/directives/functional/selectOnClick.js similarity index 100% rename from app/assets/javascripts/app/services/directives/selectOnClick.js rename to app/assets/javascripts/app/services/directives/functional/selectOnClick.js diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js new file mode 100644 index 000000000..5e9d74e62 --- /dev/null +++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js @@ -0,0 +1,235 @@ +class AccountMenu { + + constructor() { + this.restrict = "E"; + this.templateUrl = "frontend/directives/account-menu.html"; + this.scope = {}; + } + + controller($scope, authManager, modelManager, syncManager, $timeout) { + 'ngInject'; + + $scope.formData = {url: syncManager.serverURL}; + $scope.user = authManager.user; + $scope.server = syncManager.serverURL; + + $scope.syncStatus = syncManager.syncStatus; + + $scope.changePasswordPressed = function() { + $scope.showNewPasswordForm = !$scope.showNewPasswordForm; + } + + $scope.encryptionKey = function() { + return syncManager.masterKey; + } + + $scope.serverPassword = function() { + return syncManager.serverPassword; + } + + $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; + } + + authManager.changePassword($scope.passwordChangeData.current_password, $scope.passwordChangeData.new_password, function(response){ + + }) + + }) + } + + $scope.loginSubmitPressed = function() { + $scope.formData.status = "Generating Login Keys..."; + console.log("logging in with url", $scope.formData.url); + $timeout(function(){ + authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){ + if(!response || response.error) { + $scope.formData.status = null; + var error = response ? response.error : {message: "An unknown error occured."} + if(!response || (response && !response.didDisplayAlert)) { + alert(error.message); + } + } else { + $scope.onAuthSuccess(); + } + }); + }) + } + + $scope.submitRegistrationForm = function() { + $scope.formData.status = "Generating Account Keys..."; + + $timeout(function(){ + authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){ + if(!response || response.error) { + $scope.formData.status = null; + var error = response ? response.error : {message: "An unknown error occured."} + alert(error.message); + } else { + $scope.onAuthSuccess(); + } + }); + }) + } + + $scope.onAuthSuccess = function() { + syncManager.markAllItemsDirtyAndSaveOffline(function(){ + window.location.reload(); + }) + } + + $scope.destroyLocalData = function() { + if(!confirm("Are you sure you want to end your session? This will delete all local items and extensions.")) { + return; + } + + syncManager.destroyLocalData(function(){ + window.location.reload(); + }) + } + + /* Import/Export */ + + $scope.archiveFormData = {encrypted: $scope.user ? true : false}; + $scope.user = authManager.user; + + $scope.downloadDataArchive = function() { + var link = document.createElement('a'); + link.setAttribute('download', 'notes.json'); + + var ek = $scope.archiveFormData.encrypted ? syncManager.masterKey : null; + + link.href = $scope.itemsDataFile(ek); + link.click(); + } + + $scope.submitImportPassword = function() { + $scope.performImport($scope.importData.data, $scope.importData.password); + } + + $scope.performImport = function(data, password) { + $scope.importData.loading = true; + // allow loading indicator to come up with timeout + $timeout(function(){ + $scope.importJSONData(data, password, function(response){ + $timeout(function(){ + $scope.importData.loading = false; + $scope.importData = null; + if(!response) { + alert("There was an error importing your data. Please try again."); + } + }) + }) + }) + } + + $scope.importFileSelected = function(files) { + $scope.importData = {}; + + var file = files[0]; + var reader = new FileReader(); + reader.onload = function(e) { + var data = JSON.parse(e.target.result); + $timeout(function(){ + if(data.auth_params) { + // request password + $scope.importData.requestPassword = true; + $scope.importData.data = data; + } else { + $scope.performImport(data, null); + } + }) + } + + reader.readAsText(file); + } + + $scope.encryptionStatusForNotes = function() { + var allNotes = modelManager.filteredNotes; + return allNotes.length + "/" + allNotes.length + " notes encrypted"; + } + + $scope.importJSONData = function(data, password, callback) { + console.log("Importing data", data); + + var onDataReady = function() { + var items = modelManager.mapResponseItemsToLocalModels(data.items); + items.forEach(function(item){ + item.setDirty(true); + item.markAllReferencesDirty(); + }) + + syncManager.sync(callback, {additionalFields: ["created_at", "updated_at"]}); + }.bind(this) + + if(data.auth_params) { + Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){ + var mk = keys.mk; + try { + EncryptionHelper.decryptMultipleItems(data.items, mk, true); + // delete items enc_item_key since the user's actually key will do the encrypting once its passed off + data.items.forEach(function(item){ + item.enc_item_key = null; + item.auth_hash = null; + }) + onDataReady(); + } + catch (e) { + console.log("Error decrypting", e); + alert("There was an error decrypting your items. Make sure the password you entered is correct and try again."); + callback(null); + return; + } + }.bind(this)); + } else { + onDataReady(); + } + } + + /* + Export + */ + + $scope.itemsDataFile = function(ek) { + var textFile = null; + var makeTextFile = function (text) { + var data = new Blob([text], {type: 'text/json'}); + + // If we are replacing a previously generated file we need to + // manually revoke the object URL to avoid memory leaks. + if (textFile !== null) { + window.URL.revokeObjectURL(textFile); + } + + textFile = window.URL.createObjectURL(data); + + // returns a URL you can use as a href + return textFile; + }.bind(this); + + var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){ + var itemParams = new ItemParams(item, ek); + return itemParams.paramsForExportFile(); + }.bind(this)); + + var data = { + items: items + } + + if(ek) { + // auth params are only needed when encrypted with a standard file key + data["auth_params"] = authManager.getAuthParams(); + } + + return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */)); + } + + } +} + +angular.module('app.frontend').directive('accountMenu', () => new AccountMenu); diff --git a/app/assets/javascripts/app/services/directives/contextualExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/contextualExtensionsMenu.js similarity index 100% rename from app/assets/javascripts/app/services/directives/contextualExtensionsMenu.js rename to app/assets/javascripts/app/services/directives/views/contextualExtensionsMenu.js diff --git a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js new file mode 100644 index 000000000..9945ac81c --- /dev/null +++ b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js @@ -0,0 +1,64 @@ +class GlobalExtensionsMenu { + + constructor() { + this.restrict = "E"; + this.templateUrl = "frontend/directives/global-extensions-menu.html"; + this.scope = { + }; + } + + controller($scope, extensionManager, syncManager) { + 'ngInject'; + + $scope.extensionManager = extensionManager; + + $scope.toggleExtensionForm = function() { + $scope.newExtensionData = {}; + $scope.showNewExtensionForm = !$scope.showNewExtensionForm; + } + + $scope.submitNewExtensionForm = function() { + if($scope.newExtensionData.url) { + extensionManager.addExtension($scope.newExtensionData.url, function(response){ + if(!response) { + alert("Unable to register this extension. Make sure the link is valid and try again."); + } else { + $scope.newExtensionData.url = ""; + $scope.showNewExtensionForm = false; + } + }) + } + } + + $scope.selectedAction = function(action, extension) { + extensionManager.executeAction(action, extension, null, function(response){ + if(response && response.error) { + action.error = true; + alert("There was an error performing this action. Please try again."); + } else { + action.error = false; + syncManager.sync(null); + } + }) + } + + $scope.changeExtensionEncryptionFormat = function(encrypted, extension) { + extensionManager.changeExtensionEncryptionFormat(encrypted, extension); + } + + $scope.deleteExtension = function(extension) { + if(confirm("Are you sure you want to delete this extension?")) { + extensionManager.deleteExtension(extension); + } + } + + $scope.reloadExtensionsPressed = function() { + if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) { + extensionManager.refreshExtensionsFromServer(); + } + } + } + +} + +angular.module('app.frontend').directive('globalExtensionsMenu', () => new GlobalExtensionsMenu); diff --git a/app/assets/javascripts/app/services/extensionManager.js b/app/assets/javascripts/app/services/extensionManager.js index 4be4ecff0..ff44b6350 100644 --- a/app/assets/javascripts/app/services/extensionManager.js +++ b/app/assets/javascripts/app/services/extensionManager.js @@ -1,11 +1,12 @@ class ExtensionManager { - constructor(Restangular, modelManager, apiController) { + constructor(Restangular, modelManager, authManager, syncManager) { this.Restangular = Restangular; this.modelManager = modelManager; - this.apiController = apiController; + this.authManager = authManager; this.enabledRepeatActionUrls = JSON.parse(localStorage.getItem("enabledRepeatActionUrls")) || []; this.decryptedExtensions = JSON.parse(localStorage.getItem("decryptedExtensions")) || []; + this.syncManager = syncManager; modelManager.addItemSyncObserver("extensionManager", "Extension", function(items){ for (var ext of items) { @@ -42,6 +43,7 @@ class ExtensionManager { } changeExtensionEncryptionFormat(encrypted, extension) { + console.log("changing encryption status"); if(encrypted) { _.pull(this.decryptedExtensions, extension.url); } else { @@ -68,7 +70,7 @@ class ExtensionManager { } this.modelManager.setItemToBeDeleted(extension); - this.apiController.sync(null); + this.syncManager.sync(null); } /* @@ -111,7 +113,7 @@ class ExtensionManager { extension.url = url; extension.setDirty(true); this.modelManager.addItem(extension); - this.apiController.sync(null); + this.syncManager.sync(null); } return extension; @@ -134,22 +136,30 @@ class ExtensionManager { executeAction(action, extension, item, callback) { - if(this.extensionUsesEncryptedData(extension) && !this.apiController.isUserSignedIn()) { + if(this.extensionUsesEncryptedData(extension) && this.authManager.offline()) { alert("To send data encrypted, you must have an encryption key, and must therefore be signed in."); callback(null); return; } + var customCallback = function(response) { + action.running = false; + callback(response); + } + + action.running = true; + switch (action.verb) { case "get": { this.Restangular.oneUrl(action.url, action.url).get().then(function(response){ action.error = false; var items = response.items; this.modelManager.mapResponseItemsToLocalModels(items); - callback(items); + customCallback(items); }.bind(this)) .catch(function(response){ action.error = true; + customCallback(null); }) break; @@ -158,7 +168,7 @@ class ExtensionManager { case "show": { var win = window.open(action.url, '_blank'); win.focus(); - callback(); + customCallback(); break; } @@ -177,7 +187,7 @@ class ExtensionManager { } this.performPost(action, extension, params, function(response){ - callback(response); + customCallback(response); }); break; @@ -261,20 +271,25 @@ class ExtensionManager { var params = this.outgoingParamsForItem(item, extension); return params; }.bind(this)) - this.performPost(action, extension, params, null); + + action.running = true; + this.performPost(action, extension, params, function(){ + action.running = false; + }); } else { // todo } } outgoingParamsForItem(item, extension) { - return this.apiController.paramsForExtension(item, this.extensionUsesEncryptedData(extension)); + var itemParams = new ItemParams(item, this.syncManager.masterKey); + return itemParams.paramsForExtension(); } performPost(action, extension, params, callback) { var request = this.Restangular.oneUrl(action.url, action.url); if(this.extensionUsesEncryptedData(extension)) { - request.auth_params = this.apiController.getAuthParams(); + request.auth_params = this.authManager.getAuthParams(); } _.merge(request, params); diff --git a/app/assets/javascripts/app/services/filters/startFrom.js b/app/assets/javascripts/app/services/filters/startFrom.js new file mode 100644 index 000000000..2ebd72e42 --- /dev/null +++ b/app/assets/javascripts/app/services/filters/startFrom.js @@ -0,0 +1,6 @@ +// Start from filter +angular.module('app.frontend').filter('startFrom', function() { + return function(input, start) { + return input.slice(start); + }; +}); diff --git a/app/assets/javascripts/app/services/helpers/encryptionHelper.js b/app/assets/javascripts/app/services/helpers/encryptionHelper.js new file mode 100644 index 000000000..7cf011ac8 --- /dev/null +++ b/app/assets/javascripts/app/services/helpers/encryptionHelper.js @@ -0,0 +1,64 @@ +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 ek = Neeto.crypto.firstHalfOfKey(item_key); + var ak = Neeto.crypto.secondHalfOfKey(item_key); + var encryptedContent = "001" + Neeto.crypto.encryptText(JSON.stringify(item.createContentJSONFromProperties()), ek); + var authHash = Neeto.crypto.hmac256(encryptedContent, ak); + + item.content = encryptedContent; + item.auth_hash = authHash; + } + + static decryptItem(item, key) { + var item_key = Neeto.crypto.decryptText(item.enc_item_key, key); + + var ek = Neeto.crypto.firstHalfOfKey(item_key); + var ak = Neeto.crypto.secondHalfOfKey(item_key); + var authHash = Neeto.crypto.hmac256(item.content, ak); + if(authHash !== item.auth_hash || !item.auth_hash) { + console.log("Authentication hash does not match.") + return; + } + + var content = Neeto.crypto.decryptText(item.content.substring(3, item.content.length), ek); + item.content = content; + } + + static decryptMultipleItems(items, key, throws) { + for (var item of items) { + if(item.deleted == true) { + continue; + } + + var isString = typeof item.content === 'string' || item.content instanceof String; + if(isString) { + try { + if(item.content.substring(0, 3) == "001" && item.enc_item_key) { + // is encrypted + this.decryptItem(item, key); + } else { + // is base64 encoded + item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length)) + } + } catch (e) { + if(throws) { + throw e; + } + console.log("Error decrypting item", item, e); + continue; + } + } + } + } + +} diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index 9b8d62e70..aa436b6e2 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -22,6 +22,18 @@ class ModelManager { }) } + alternateUUIDForItem(item, callback) { + // we need to clone this item and give it a new uuid, then delete item with old uuid from db (you can't mofidy uuid's in our indexeddb setup) + var newItem = this.createItem(item); + newItem.uuid = Neeto.crypto.generateUUID(); + this.removeItemLocally(item, function(){ + this.addItem(newItem); + newItem.setDirty(true); + newItem.markAllReferencesDirty(); + callback(); + }.bind(this)); + } + allItemsMatchingTypes(contentTypes) { return this.items.filter(function(item){ return (_.includes(contentTypes, item.content_type) || _.includes(contentTypes, "*")) && !item.dummy; @@ -76,7 +88,6 @@ class ModelManager { this.notifySyncObserversOfModels(models); - this.sortItems(); return models; } @@ -133,7 +144,9 @@ class ModelManager { } } else if(item.content_type == "Note") { if(!_.find(this.notes, {uuid: item.uuid})) { - this.notes.unshift(item); + this.notes.splice(_.sortedLastIndexBy(this.notes, item, function(item){ + return -item.created_at; + }), 0, item); } } else if(item.content_type == "Extension") { if(!_.find(this._extensions, {uuid: item.uuid})) { @@ -170,14 +183,6 @@ class ModelManager { } } - sortItems() { - Item.sortItemsByDate(this.notes); - - this.tags.forEach(function(tag){ - Item.sortItemsByDate(tag.notes); - }) - } - addItemSyncObserver(id, type, callback) { this.itemSyncObservers.push({id: id, type: type, callback: callback}); } @@ -220,18 +225,21 @@ class ModelManager { item.removeAllRelationships(); } - removeItemLocally(item) { + removeItemLocally(item, callback) { _.pull(this.items, item); + item.isBeingRemovedLocally(); + if(item.content_type == "Tag") { _.pull(this.tags, item); } else if(item.content_type == "Note") { _.pull(this.notes, item); + } else if(item.content_type == "Extension") { _.pull(this._extensions, item); } - this.dbManager.deleteItem(item); + this.dbManager.deleteItem(item, callback); } /* diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js new file mode 100644 index 000000000..66344b15a --- /dev/null +++ b/app/assets/javascripts/app/services/syncManager.js @@ -0,0 +1,245 @@ +class SyncManager { + + constructor($rootScope, modelManager, authManager, dbManager, Restangular) { + this.$rootScope = $rootScope; + this.modelManager = modelManager; + this.authManager = authManager; + this.Restangular = Restangular; + this.dbManager = dbManager; + this.syncStatus = {}; + } + + get serverURL() { + return localStorage.getItem("server") || "https://n3.standardnotes.org"; + } + + get masterKey() { + return localStorage.getItem("mk"); + } + + get serverPassword() { + return localStorage.getItem("pw"); + } + + writeItemsToLocalStorage(items, offlineOnly, callback) { + var params = items.map(function(item) { + var itemParams = new ItemParams(item, null); + itemParams = itemParams.paramsForLocalStorage(); + if(offlineOnly) { + delete itemParams.dirty; + } + return itemParams; + }.bind(this)); + + this.dbManager.saveItems(params, callback); + } + + loadLocalItems(callback) { + var params = this.dbManager.getAllItems(function(items){ + var items = this.handleItemsResponse(items, null, null); + Item.sortItemsByDate(items); + callback(items); + }.bind(this)) + } + + syncOffline(items, callback) { + this.writeItemsToLocalStorage(items, true, function(responseItems){ + // delete anything needing to be deleted + for(var item of items) { + if(item.deleted) { + this.modelManager.removeItemLocally(item); + } + } + + if(callback) { + callback({success: true}); + } + }.bind(this)) + + } + + markAllItemsDirtyAndSaveOffline(callback) { + var items = this.modelManager.allItems; + for(var item of items) { + item.setDirty(true); + } + this.writeItemsToLocalStorage(items, false, callback); + } + + get syncURL() { + return this.serverURL + "/items/sync"; + } + + set syncToken(token) { + this._syncToken = token; + localStorage.setItem("syncToken", token); + } + + get syncToken() { + if(!this._syncToken) { + this._syncToken = localStorage.getItem("syncToken"); + } + return this._syncToken; + } + + set cursorToken(token) { + this._cursorToken = token; + if(token) { + localStorage.setItem("cursorToken", token); + } else { + localStorage.removeItem("cursorToken"); + } + } + + get cursorToken() { + if(!this._cursorToken) { + this._cursorToken = localStorage.getItem("cursorToken"); + } + return this._cursorToken; + } + + sync(callback, options = {}) { + + if(this.syncStatus.syncOpInProgress) { + this.repeatOnCompletion = true; + console.log("Sync op in progress; returning."); + return; + } + + var allDirtyItems = this.modelManager.getDirtyItems(); + + // we want to write all dirty items to disk only if the user is offline, or if the sync op fails + // if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server + if(this.authManager.offline()) { + this.syncOffline(allDirtyItems, callback); + this.modelManager.clearDirtyItems(allDirtyItems); + return; + } + + var isContinuationSync = this.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; + } else { + this.needsMoreSync = false; + } + + if(!isContinuationSync) { + this.syncStatus.total = allDirtyItems.length; + this.syncStatus.current = 0; + } + + var request = this.Restangular.oneUrl(this.syncURL, this.syncURL); + request.limit = 150; + request.items = _.map(subItems, function(item){ + var itemParams = new ItemParams(item, localStorage.getItem("mk")); + itemParams.additionalFields = options.additionalFields; + return itemParams.paramsForSync(); + }.bind(this)); + + request.sync_token = this.syncToken; + request.cursor_token = this.cursorToken; + + request.post().then(function(response) { + this.modelManager.clearDirtyItems(subItems); + this.syncStatus.error = null; + + this.$rootScope.$broadcast("sync:updated_token", this.syncToken); + + var retrieved = this.handleItemsResponse(response.retrieved_items, null); + // merge only metadata for saved items + var omitFields = ["content", "auth_hash"]; + var saved = this.handleItemsResponse(response.saved_items, omitFields); + + this.handleUnsavedItemsResponse(response.unsaved) + + this.writeItemsToLocalStorage(saved, false, null); + this.writeItemsToLocalStorage(retrieved, false, null); + + this.syncStatus.syncOpInProgress = false; + this.syncStatus.current += subItems.length; + + // set the sync token at the end, so that if any errors happen above, you can resync + this.syncToken = response.sync_token; + this.cursorToken = response.cursor_token; + + if(this.cursorToken || this.repeatOnCompletion || this.needsMoreSync) { + setTimeout(function () { + this.sync(callback, options); + }.bind(this), 10); // wait 10ms to allow UI to update + } else { + if(callback) { + callback(response); + } + } + + }.bind(this)) + .catch(function(response){ + console.log("Sync error: ", response); + var error = response.data ? response.data.error : {message: "Could not connect to server."}; + + this.syncStatus.syncOpInProgress = false; + this.syncStatus.error = error; + this.writeItemsToLocalStorage(allDirtyItems, false, null); + + this.$rootScope.$broadcast("sync:error", error); + + if(callback) { + callback({error: "Sync error"}); + } + }.bind(this)) + } + + handleUnsavedItemsResponse(unsaved) { + if(unsaved.length == 0) { + return; + } + + console.log("Handle unsaved", unsaved); + + var i = 0; + var handleNext = function() { + if (i < unsaved.length) { + var mapping = unsaved[i]; + var itemResponse = mapping.item; + 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 + this.modelManager.alternateUUIDForItem(item, handleNext); + } + ++i; + } else { + this.sync(null, {additionalFields: ["created_at", "updated_at"]}); + } + }.bind(this); + + handleNext(); + } + + handleItemsResponse(responseItems, omitFields) { + EncryptionHelper.decryptMultipleItems(responseItems, localStorage.getItem("mk")); + return this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields); + } + + clearSyncToken() { + localStorage.removeItem("syncToken"); + } + + destroyLocalData(callback) { + this.dbManager.clearAllItems(function(){ + localStorage.clear(); + if(callback) { + callback(); + } + }); + } +} + +angular.module('app.frontend').service('syncManager', SyncManager); diff --git a/app/assets/stylesheets/app/_directives.scss b/app/assets/stylesheets/app/_directives.scss index 4ca0da65b..39eb623dc 100644 --- a/app/assets/stylesheets/app/_directives.scss +++ b/app/assets/stylesheets/app/_directives.scss @@ -5,7 +5,6 @@ margin-top: 18px; } - .ext-header { background-color: #ededed; border-bottom: 1px solid #d3d3d3; @@ -64,9 +63,7 @@ margin-top: 1px; font-size: 12px; } - } - } } } diff --git a/app/assets/stylesheets/app/_header.scss b/app/assets/stylesheets/app/_header.scss index 5e5e5208f..74efb241a 100644 --- a/app/assets/stylesheets/app/_header.scss +++ b/app/assets/stylesheets/app/_header.scss @@ -1,6 +1,155 @@ -.header { +.pull-left { + float: left !important; +} + +.pull-right { + float: right !important; +} + +.mt-1 { + margin-top: 1px !important; +} + +.mt-2 { + margin-top: 2px !important; +} + +.mt-5 { + margin-top: 5px !important; +} + +.mt-10 { + margin-top: 10px !important; +} + +.mt-15 { + margin-top: 15px !important; +} + +.mt-25 { + margin-top: 25px !important; +} + +.mb-0 { + margin-bottom: 0px !important; +} + +.mb-5 { + margin-bottom: 5px !important; +} + +.mb-10 { + margin-bottom: 10px !important; +} + +.mr-5 { + margin-right: 5px; +} + +.faded { + opacity: 0.5; +} + +.center-align { + text-align: center !important; +} + +.center { + margin-left: auto !important; + margin-right: auto !important; +} + +.block { + display: block !important; +} + +.wrap { + word-wrap: break-word; +} + +.one-line-overflow { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.small-v-space { + height: 6px; + display: block; +} + +.medium-v-space { + height: 12px; + display: block; +} + +.large-v-space { + height: 24px; + display: block; +} + +.small-padding { + padding: 5px !important; +} + +.medium-padding { + padding: 10px !important; +} + +.pb-4 { + padding-bottom: 4px !important; +} + +.pb-6 { + padding-bottom: 6px !important; +} + +.pb-10 { + padding-bottom: 10px !important; +} + +.large-padding { + padding: 22px !important; +} + +.red { + color: red !important; +} + +.blue { + color: $blue-color; +} + +.bold { + font-weight: bold !important; +} + +.normal { + font-weight: normal !important; +} + +.small { + font-size: 10px !important; +} + +.inline { + display: inline-block; +} + +.fake-link { + font-weight: bold; + cursor: pointer; + color: $blue-color; + + &:hover { + text-decoration: underline; + } +} + +.footer-bar { position: relative; width: 100%; + padding: 5px; background-color: #d8d7d9; height: $header-height; max-height: $header-height; @@ -9,90 +158,125 @@ color: $dark-gray; border-bottom: 1px solid rgba(#979799, 0.4); + .medium-text { + font-size: 14px; + } + a { - color: $dark-gray; + font-weight: bold; + cursor: pointer; + color: $blue-color; + + &.gray { + color: $dark-gray !important; + } + + &.block { + display: block !important; + } + } + + p { + margin: 2px 0px; + font-size: 12px; + } + + label { + font-weight: bold; + margin-bottom: 4px; + } + + strong { + display: block; + } + + h2 { + margin-bottom: 0px; + margin-top: 0px; + } + + h3 { + font-size: 14px !important; + margin-top: 4px !important; + margin-bottom: 3px !important; + } + + h4 { + margin-bottom: 4px !important; + font-size: 13px !important; + } + + section { + padding: 5px; + padding-bottom: 2px; + margin-top: 5px; + + &.inline-h { + padding-top: 5px; + padding-left: 0; + padding-right: 0; + } + + } + + input { + margin-bottom: 10px; + border-radius: 0px; } } -.header-content { - margin-bottom: 0px; - padding-top: 0px; - border-radius: 0px; - - left: 0px; - right: 0px; -} - -.panel-status-text { - margin-top: 20px; - font-style: italic; - font-size: 14px; -} - -.menu { - margin-left: 15px; - padding-top: 5px; - margin-top: 0px; - color: #515263; - z-index: 1000; - margin-bottom: 0px; +.footer-bar-link { font-size: 11px; + font-weight: bold; + margin-left: 8px; + color: #515263; - &.left { - float: left; + z-index: 1000; + display: inline-block; + position: relative; + cursor: pointer; + + > a { + color: #515263; } +} - &.right { - float: right; - margin-right: 10px; - } +.footer-bar-link .panel { + font-weight: normal; + font-size: 12px; - .login-panel .login-input { - border-radius: 0px; - } + max-height: 85vh; + position: absolute; + right: 0px; + bottom: 20px; + min-width: 300px; + z-index: 1000; + margin-top: 15px; - .items { + box-shadow: 0px 0px 15px rgba(black, 0.2); + border: none; + cursor: default; + overflow: auto; + background-color: white; +} - .item { +button.light { + font-weight: bold; + margin-bottom: 0px; + font-size: 12px; + height: 30px; + padding-top: 3px; + text-align: center; + margin-bottom: 6px; + background-color: white; + display: block; + width: 100%; + border: 1px solid rgba(gray, 0.15); + cursor: pointer; + color: $blue-color; - display: inline-block; - margin-right: 7px; - position: relative; - cursor: pointer; - font-weight: bold; - - a { - color: #515263; - } - - .panel { - position: absolute; - right: 0px; - bottom: 20px; - min-width: 300px; - z-index: 1000; - margin-top: 15px; - box-shadow: 0px 0px 15px rgba(black, 0.2); - border: none; - cursor: default; - max-height: 85vh; - overflow: auto; - background-color: white; - font-weight: normal; - - - .storage-text { - font-size: 14px; - } - - .checkbox { - font-size: 14px; - font-weight: normal; - margin-left: auto; - margin-right: auto; - } - } - } + &:hover { + background-color: rgba(gray, 0.10); } } @@ -104,162 +288,25 @@ float: left; } +.gray-bg { + background-color: #f6f6f6; + border: 1px solid #f2f2f2; +} + +.white-bg { + background-color: white; + border: 1px solid rgba(gray, 0.2); +} + .item.last-refreshed { font-weight: normal !important; cursor: default !important; } -.item.account { - - .email { - font-size: 18px; - font-weight: bold; - margin-bottom: 2px; - } - - .server { - margin-bottom: 10px; - } - - .links { - margin-bottom: 25px; - } - - .link-item { - margin-bottom: 8px; - a { - font-size: 12px; - color: $blue-color; - font-weight: bold; - } - } - - input { - border-radius: 0px; - } - - .account-panel { - - padding: 12px; - padding-bottom: 6px; - - .account-items { - margin-top: 0px; - } - - .account-item { - width: 100%; - margin-bottom: 34px; - - a { - color: $blue-color; - font-weight: bold; - cursor: pointer; - } - - > .icon-container { - display: block; - margin-bottom: 10px; - } - - > .meta-container { - display: block; - font-size: 10px; - } - - > .action-container { - font-size: 12px; - margin-top: 6px; - - .status-title { - font-weight: bold; - } - - .subtext { - font-size: 10px; - margin-top: 2px; - } - - a { - display: block; - margin-bottom: -10px; - } - } - - .import-password { - margin-top: 14px; - - > .field { - display: block; - margin: 5px 0px; - } - } - - .encryption-confirmation { - position: relative; - .buttons { - .cancel { - font-weight: normal; - margin-right: 3px; - } - } - } - - &:last-child { - margin-bottom: 8px !important; - } - - > .icon-container { - margin-bottom: 10px; - .icon { - height: 35px; - &.archive { - height: 30px; - } - } - } - - .meta-container { - > .title { - font-size: 13px; - font-weight: bold; - } - - > .desc { - font-size: 12px; - margin-top: 3px; - } - } - } - - .membership-settings { - font-size: 14px; - } - - } -} - a.disabled { pointer-events: none; } -.account-form { - margin-top: 10px; -} - -.registration-login { - - .login-forgot { - margin-top: 20px; - clear: both; - a { - display: block; - font-size: 13px !important; - text-align: center; - } - } -} - .spinner { height: 10px; width: 10px; @@ -267,138 +314,14 @@ a.disabled { border: 1px solid #515263; border-right-color: transparent; border-radius: 50%; + + &.blue { + border: 1px solid $blue-color; + border-right-color: transparent; + } } @keyframes rotate { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } - -/** -Extensions -*/ - -.extensions-panel { - font-size: 14px; - - .extension-link { - margin-top: 8px; - - a { - color: $blue-color !important; - font-weight: bold; - } - } -} - -.extension-form { - margin-top: 8px; -} - -.registered-extensions { - - - .extension { - margin-bottom: 18px; - background-color: #f6f6f6; - border: 1px solid #f2f2f2; - padding: 14px 6px; - padding-bottom: 8px; - color: black; - - a { - color: $blue-color !important; - font-size: 12px !important; - font-weight: bold !important; - } - - > .name { - font-weight: bold; - font-size: 16px; - margin-bottom: 6px; - text-align: center; - } - - .encryption-format { - margin-top: 4px; - font-size: 12px; - text-align: center; - - > .title { - font-size: 13px; - // font-weight: bold; - margin-bottom: 2px; - } - } - - > .subtitle { - font-size: 14px; - margin-bottom: 10px; - } - - > .actions { - margin-top: 15px; - font-size: 12px; - - .action { - padding: 13px; - margin-bottom: 10px; - background-color: rgba(white, 0.9); - border: 1px solid rgba(gray, 0.15); - - > .name { - font-weight: bold; - } - - > .permissions { - margin-top: 2px; - a { - font-weight: normal !important; - } - } - - > .execute { - font-weight: bold; - margin-bottom: 0px; - font-size: 12px; - height: 30px; - padding-top: 7px; - text-align: center; - margin-top: 6px; - border: 1px solid rgba(gray, 0.15); - cursor: pointer; - color: $blue-color; - - &:hover { - background-color: rgba(gray, 0.10); - } - - .execution-spinner { - margin-left: auto; - margin-right: auto; - text-align: center; - margin-top: 3px; - } - } - - > .execute-type { - font-size: 12px; - margin-bottom: 1px; - } - - > .error { - color: red; - margin-top: 6px; - } - - > .last-run { - opacity: 0.5; - font-size: 11px; - margin-top: 6px; - } - } - - } - } - -} diff --git a/app/assets/stylesheets/app/_notes.scss b/app/assets/stylesheets/app/_notes.scss index 6cb3adcce..d71413110 100644 --- a/app/assets/stylesheets/app/_notes.scss +++ b/app/assets/stylesheets/app/_notes.scss @@ -41,12 +41,11 @@ .note { width: 100%; - // max-width: 100%; padding: 15px; - // height: 70px; border-bottom: 1px solid $bg-color; cursor: pointer; background-color: white; + > .name { font-weight: 600; overflow: hidden; diff --git a/app/assets/templates/frontend/directives/account-menu.html.haml b/app/assets/templates/frontend/directives/account-menu.html.haml new file mode 100644 index 000000000..168b1ebf4 --- /dev/null +++ b/app/assets/templates/frontend/directives/account-menu.html.haml @@ -0,0 +1,77 @@ +.panel.panel-default.panel-right.account-data-menu + .panel-body.large-padding + %div{"ng-if" => "!user"} + %p Enter your Standard File account information. You can also register for free using the default server address. + .small-v-space + + %form.account-form.mt-5{'name' => "loginForm"} + %input.form-control{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'formData.url'} + %input.form-control{:autofocus => 'autofocus', :name => 'email', :placeholder => 'Email', :required => true, :type => 'email', 'ng-model' => 'formData.email'} + %input.form-control{:placeholder => 'Password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'formData.user_password'} + + %div{"ng-if" => "!formData.status"} + %button.btn.dark-button.half-button{"ng-click" => "loginSubmitPressed()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} + %span Sign In + %button.btn.dark-button.half-button{"ng-click" => "submitRegistrationForm()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} + %span Register + %br + .block{"style" => "margin-top: 10px; font-size: 14px; font-weight: bold; text-align: center;"} + %a.btn{"ng-click" => "showResetForm = !showResetForm"} Passwords cannot be forgotten. + + %em.block.center-align.mt-10{"ng-if" => "formData.status", "style" => "font-size: 14px;"} {{formData.status}} + + %div{"ng-if" => "showResetForm"} + %p{"style" => "font-size: 13px; text-align: center;"} + Because notes are locally encrypted using a secret key derived from your password, there's no way to decrypt these notes if you forget your password. + For this reason, Standard Notes cannot offer a password reset option. You must make sure to store or remember your password. + + %div{"ng-if" => "user"} + %h2 {{user.email}} + %p {{server}} + %a.block.mt-5{"ng-click" => "showCredentials = !showCredentials"} Show Credentials + %section.gray-bg.mt-10.medium-padding{"ng-if" => "showCredentials"} + %label.block + Encryption key: + .wrap.normal.mt-1 {{encryptionKey()}} + %label.block.mt-5.mb-0 + Server password: + .wrap.normal.mt-1 {{serverPassword() ? serverPassword() : 'Not available. Sign out then sign back in to compute.'}} + + %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}} + + .medium-v-space + + %h4 Local Encryption + %p Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes. + %div.mt-5 + %label Status: + {{encryptionStatusForNotes()}} + + .mt-25{"ng-if" => "!importData.loading"} + %h4 Data Archives + .mt-5{"ng-if" => "user"} + %label.normal.inline{"ng-if" => "user"} + %input{"type" => "radio", "ng-model" => "archiveFormData.encrypted", "ng-value" => "true", "ng-change" => "archiveFormData.encrypted = true"} + Encrypted + %label.normal.inline + %input{"type" => "radio", "ng-model" => "archiveFormData.encrypted", "ng-value" => "false", "ng-change" => "archiveFormData.encrypted = false"} + Decrypted + + %a.block{"ng-click" => "downloadDataArchive()"} Download Data Archive + + %label.block.mt-5 + %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"} + .fake-link Import Data from Archive + + %div{"ng-if" => "importData.requestPassword"} + Enter the account password associated with the import file. + %input{"type" => "text", "ng-model" => "importData.password"} + %button{"ng-click" => "submitImportPassword()"} Decrypt & Import + + .spinner.mt-10{"ng-if" => "importData.loading"} + + %a.block.mt-25.red{"ng-click" => "destroyLocalData()"} Destroy all local data diff --git a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml new file mode 100644 index 000000000..2dd5df9d1 --- /dev/null +++ b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml @@ -0,0 +1,57 @@ +.panel.panel-default.account-panel.panel-right + .panel-body + %div{"style" => "font-size: 18px;", "ng-if" => "!extensionManager.extensions.length"} No extensions installed + %div{"ng-if" => "extensionManager.extensions.length"} + %section.gray-bg.inline-h.mb-10.medium-padding{"ng-repeat" => "extension in extensionManager.extensions", "ng-init" => "extension.formData = {}"} + %h3.center-align {{extension.name}} + .center-align.centered.mt-10 + %label.block.normal Send data: + %label.normal + %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "true", "ng-change" => "changeExtensionEncryptionFormat(true, extension)"} + Encrypted + %label.normal + %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "false", "ng-change" => "changeExtensionEncryptionFormat(false, extension)"} + Decrypted + + .small-v-space + + %section.inline-h.white-bg.medium-padding.mb-10.pb-4{"ng-repeat" => "action in extension.actionsInGlobalContext()"} + %label.block {{action.label}} + %em{"style" => "font-style: italic;"} {{action.desc}} + %em{"ng-if" => "action.repeat_mode == 'watch'"} + Repeats when a change is made to your items. + %em{"ng-if" => "action.repeat_mode == 'loop'"} + Repeats at most once every {{action.repeat_timeout}} seconds + %div + %a{"ng-click" => "action.showPermissions = !action.showPermissions"} {{action.showPermissions ? "Hide permissions" : "Show permissions"}} + %div{"ng-if" => "action.showPermissions"} + {{action.permissionsString()}} + %label.block.normal {{action.encryptionModeString()}} + + %div + .mt-5{"ng-if" => "action.repeat_mode"} + %button.light{"ng-if" => "extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.disableRepeatAction(action, extension)"} Disable + %button.light{"ng-if" => "!extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.enableRepeatAction(action, extension)"} Enable + %button.light.mt-10{"ng-if" => "!action.running && !action.repeat_mode", "ng-click" => "selectedAction(action, extension)"} + Perform Action + .spinner.mb-5.centered.center-align.block{"ng-if" => "action.running"} + %p.mb-5.mt-5.small{"ng-if" => "!action.error && action.lastExecuted && !action.running"} + Last run {{action.lastExecuted | appDateTime}} + %label.red{"ng-if" => "action.error"} + Error performing action. + + %a.block.center-align.mt-10{"ng-click" => "extension.showURL = !extension.showURL"} Show URL + %p.center-align.wrap{"ng-if" => "extension.showURL"} {{extension.url}} + %a.block.center-align.mt-5{"ng-click" => "deleteExtension(extension)"} Remove extension + + .large-v-space + + %a.block{"ng-click" => "toggleExtensionForm()"} Add new extension + + %form.mt-10.mb-10{"ng-if" => "showNewExtensionForm"} + %input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Extension URL', :required => true, :type => 'url', 'ng-model' => 'newExtensionData.url'} + %button.btn.dark-button.btn-block{"ng-click" => "submitNewExtensionForm()", :type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} + Add Extension + + %a.block.mt-5{"ng-click" => "reloadExtensionsPressed()", "ng-if" => "extensionManager.extensions.length > 0"} Reload all extensions + %a.block.mt-5{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} List of available extensions diff --git a/app/assets/templates/frontend/editor.html.haml b/app/assets/templates/frontend/editor.html.haml index 322b42d7f..50461f012 100644 --- a/app/assets/templates/frontend/editor.html.haml +++ b/app/assets/templates/frontend/editor.html.haml @@ -5,7 +5,7 @@ %input.input#note-title-editor{"ng-model" => "ctrl.note.title", "ng-keyup" => "$event.keyCode == 13 && ctrl.saveTitle($event)", "ng-change" => "ctrl.nameChanged()", "ng-focus" => "ctrl.onNameFocus()", "select-on-click" => "true"} - .save-status {{ctrl.noteStatus}} + .save-status{"ng-class" => "{'red bold': ctrl.saveError}"} {{ctrl.noteStatus}} .tags %input.tags-input{"type" => "text", "ng-keyup" => "$event.keyCode == 13 && ctrl.updateTagsFromTagsString($event, ctrl.tagsString)", "ng-model" => "ctrl.tagsString", "placeholder" => "#tags", "ng-blur" => "ctrl.updateTagsFromTagsString($event, ctrl.tagsString)"} diff --git a/app/assets/templates/frontend/header.html.haml b/app/assets/templates/frontend/header.html.haml index 5bdc562cb..f385f293c 100644 --- a/app/assets/templates/frontend/header.html.haml +++ b/app/assets/templates/frontend/header.html.haml @@ -1,170 +1,25 @@ -.header - .header-content - .menu.left - .items - .item.account - %div{"ng-click" => "ctrl.accountMenuPressed()"} - %div{"ng-if" => "ctrl.user"} Account - %div{"ng-if" => "!ctrl.user"} Sign in or Register - .panel.panel-default.account-panel.panel-right{"ng-if" => "ctrl.showAccountMenu"} - .panel-body - .account-items - .account-item.registration-login{"ng-if" => "!ctrl.user"} - .account-item - .meta-container - .title Server - .desc Enter your Standard File server address, or use the default. - .action-container - %form.account-form{'ng-submit' => 'ctrl.changeServer()', 'name' => "serverChangeForm"} - .form-tag.has-feedback - %input.form-control{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'ctrl.serverData.url'} - %button.btn.dark-button.btn-block{:type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} - %span.ladda-label Set Server - .meta-container - .title Sign in or Register - .desc - %form.account-form{'name' => "loginForm"} - .form-tag.has-feedback - %input.form-control.login-input{:autofocus => 'autofocus', :name => 'email', :placeholder => 'Email', :required => true, :type => 'email', 'ng-model' => 'ctrl.loginData.email'} - .form-tag.has-feedback - %input.form-control.login-input{:placeholder => 'Password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.loginData.user_password'} - .checkbox{"ng-if" => "ctrl.localNotesCount() > 0"} - %label - %input{"type" => "checkbox", "ng-model" => "ctrl.loginData.mergeLocal", "ng-bind" => "true", "ng-change" => "ctrl.mergeLocalChanged()"} - Merge local notes ({{ctrl.localNotesCount()}} notes) - %button.btn.dark-button.half-button{"ng-click" => "ctrl.loginSubmitPressed()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} - %span Sign In - %button.btn.dark-button.half-button{"ng-click" => "ctrl.submitRegistrationForm()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} - %span Register - %br - .login-forgot{"style" => "padding-top: 4px;"} - %a.btn.btn-link{"ng-click" => "ctrl.showResetForm = !ctrl.showResetForm"} Passwords cannot be forgotten. - .panel-status-text{"ng-if" => "ctrl.loginData.status", "style" => "font-size: 14px;"} {{ctrl.loginData.status}} +.footer-bar + .pull-left + .footer-bar-link + %a{"ng-click" => "ctrl.accountMenuPressed()", "ng-class" => "{red: ctrl.error}"} Account + %account-menu{"ng-if" => "ctrl.showAccountMenu"} - %div{"ng-if" => "ctrl.showResetForm"} - %p{"style" => "font-size: 13px; text-align: center;"} - Because notes are locally encrypted using a secret key derived from your password, there's no way to decrypt these notes if you forget your password. - For this reason, Standard Notes cannot offer a password reset option. You must make sure to store or remember your password. + .footer-bar-link + %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions + %global-extensions-menu{"ng-if" => "ctrl.showExtensionsMenu"} - .account-item{"ng-if" => "ctrl.user"} - .email {{ctrl.user.email}} - .server {{ctrl.serverData.url}} - .links{"ng-if" => "ctrl.user"} - -# .link-item - -# %a{"ng-click" => "ctrl.changePasswordPressed()"} Change Password - -# %form.account-form{"ng-if" => "ctrl.showNewPasswordForm", 'ng-submit' => 'ctrl.submitPasswordChange()', 'name' => "passwordChangeForm"} - -# .form-tag.has-feedback - -# %input.form-control.login-input{:autofocus => 'autofocus', :name => 'current', :placeholder => 'Current password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.current_password'} - -# .form-tag.has-feedback - -# %input.form-control.login-input{:placeholder => 'New password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.new_password', "autocomplete" => "new-password"} - -# .form-tag.has-feedback - -# %input.form-control.login-input{:placeholder => 'Confirm password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.new_password_confirmation', "autocomplete" => "new-password"} - -# %button.btn.dark-button.btn-block{:type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} - -# %span.ladda-label Change Password - -# .panel-status-text{"ng-if" => "ctrl.passwordChangeData.status", "style" => "font-size: 14px;"} - -# {{ctrl.passwordChangeData.status}} - .link-item - %a{"ng-click" => "ctrl.signOutPressed()"} Sign Out - .meta-container - .title Local Encryption - .desc Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes. - .action-container - %span.status-title Status: - {{ctrl.encryptionStatusForNotes()}} - .account-item{"ng-if" => "ctrl.user"} - .meta-container - .title Data Archives - .options{"style" => "font-size: 12px; margin-top: 4px;"} - %label - %input{"type" => "radio", "ng-model" => "ctrl.archiveEncryptionFormat.encrypted", "ng-value" => "true", "ng-change" => "ctrl.archiveEncryptionFormat.encrypted = true"} - Encrypted - %label - %input{"type" => "radio", "ng-model" => "ctrl.archiveEncryptionFormat.encrypted", "ng-value" => "false", "ng-change" => "ctrl.archiveEncryptionFormat.encrypted = false"} - Decrypted - .action-container - %a{"ng-click" => "ctrl.downloadDataArchive()"} Download Data Archive - %br - %div{"ng-if" => "!ctrl.importData.loading"} - %label#import-archive - %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "ctrl.importFileSelected(files)"} - %a.disabled - %span - Import Data from Archive - .import-password{"ng-if" => "ctrl.importData.requestPassword"} - Enter the account password associated with the import file. - %input.field{"type" => "text", "ng-model" => "ctrl.importData.password"} - %button{"ng-click" => "ctrl.submitImportPassword()"} Decrypt & Import - .spinner{"ng-if" => "ctrl.importData.loading"} + .footer-bar-link + %a{"href" => "https://standardnotes.org", "target" => "_blank"} + Help - .item - %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions - .panel.panel-default.account-panel.panel-right.extensions-panel{"ng-if" => "ctrl.showExtensionsMenu"} - .panel-body - %div{"style" => "font-size: 18px;", "ng-if" => "!ctrl.extensionManager.extensions.length"} No extensions installed - .registered-extensions{"ng-if" => "ctrl.extensionManager.extensions.length"} - .extension{"ng-repeat" => "extension in ctrl.extensionManager.extensions"} - .name {{extension.name}} - .encryption-format - .title Send data: - %label - %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "true", "ng-change" => "ctrl.extensionManager.changeExtensionEncryptionFormat(true, extension)"} - Encrypted - %label - %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "false", "ng-change" => "ctrl.extensionManager.changeExtensionEncryptionFormat(false, extension)"} - Decrypted - .actions - .action{"ng-repeat" => "action in extension.actionsInGlobalContext()"} - .name {{action.label}} - .desc{"style" => "font-style: italic;"} {{action.desc}} - .execute-type{"ng-if" => "action.repeat_mode == 'watch'"} - Repeats when a change is made to your items. - .execute-type{"ng-if" => "action.repeat_mode == 'loop'"} - Repeats at most once every {{action.repeat_timeout}} seconds - .permissions - %a{"ng-click" => "action.showPermissions = !action.showPermissions"} {{action.showPermissions ? "Hide permissions" : "Show permissions"}} - %div{"ng-if" => "action.showPermissions"} - {{action.permissionsString}} - .encryption-type - %span {{action.encryptionModeString}} - .execute - %div{"ng-if" => "action.repeat_mode"} - %div{"ng-if" => "ctrl.extensionManager.isRepeatActionEnabled(action)", "ng-click" => "ctrl.extensionManager.disableRepeatAction(action, extension)"} Disable - %div{"ng-if" => "!ctrl.extensionManager.isRepeatActionEnabled(action)", "ng-click" => "ctrl.extensionManager.enableRepeatAction(action, extension)"} Enable - %div{"ng-if" => "!action.repeat_mode", "ng-click" => "ctrl.selectedAction(action, extension)"} - %div{"ng-if" => "!action.running"} - Perform Action - %div{"ng-if" => "action.running"} - .spinner.execution-spinner - .last-run{"ng-if" => "!action.error && action.lastExecuted && !action.running"} - Last run {{action.lastExecuted | appDateTime}} - .error{"ng-if" => "action.error"} - Error performing action. - %a{"ng-click" => "ctrl.deleteExtension(extension)", "style" => "margin-top: 22px; display: block; text-align: center;"} Remove extension + .pull-right - .extension-link - %a{"ng-click" => "ctrl.toggleExtensionForm()"} Add new extension + .footer-bar-link{"style" => "margin-right: 5px;"} + %div{"ng-if" => "ctrl.lastSyncDate", "style" => "float: left; font-weight: normal; margin-right: 8px;"} + %span{"ng-if" => "!ctrl.isRefreshing"} + Last refreshed {{ctrl.lastSyncDate | appDateTime}} + %span{"ng-if" => "ctrl.isRefreshing"} + .spinner{"style" => "margin-top: 2px;"} - %form.extension-form{"ng-if" => "ctrl.showNewExtensionForm"} - .form-tag.has-feedback - %input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Extension URL', :required => true, :type => 'url', 'ng-model' => 'ctrl.newExtensionData.url'} - %button.btn.dark-button.btn-block{"ng-click" => "ctrl.submitNewExtensionForm()", :type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"} - %span.ladda-label Add Extension - - .extension-link - %a{"ng-click" => "ctrl.reloadExtensionsPressed()", "ng-if" => "ctrl.extensionManager.extensions.length > 0"} Reload all extensions - .extension-link - %a{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} List of available extensions - - .item - %a{"href" => "https://standardnotes.org", "target" => "_blank"} - Help - - .menu.right - .items - .item.last-refreshed{"ng-if" => "ctrl.lastSyncDate"} - %span{"ng-if" => "!ctrl.isRefreshing"} - Last refreshed {{ctrl.lastSyncDate | appDateTime}} - %span{"ng-if" => "ctrl.isRefreshing"} - .spinner - .item{"ng-click" => "ctrl.refreshData()"} - Refresh + %strong{"ng-if" => "ctrl.offline"} Offline + %a{"ng-if" => "!ctrl.offline", "ng-click" => "ctrl.refreshData()"} Refresh diff --git a/app/assets/templates/frontend/home.html.haml b/app/assets/templates/frontend/home.html.haml index f2f40d0bf..f7057c955 100644 --- a/app/assets/templates/frontend/home.html.haml +++ b/app/assets/templates/frontend/home.html.haml @@ -8,4 +8,5 @@ "tag" => "selectedTag", "remove" => "deleteNote"} %editor-section{"ng-if" => "selectedNote", "note" => "selectedNote", "remove" => "deleteNote", "save" => "saveNote", "update-tags" => "updateTagsForNote"} - %header{"user" => "defaultUser"} + + %header diff --git a/app/assets/templates/frontend/notes.html.haml b/app/assets/templates/frontend/notes.html.haml index 0d5b71103..28fb4654b 100644 --- a/app/assets/templates/frontend/notes.html.haml +++ b/app/assets/templates/frontend/notes.html.haml @@ -18,10 +18,11 @@ %li %a.text{"ng-click" => "ctrl.selectedMenuItem(); ctrl.selectedTagDelete()"} Delete Tag - .note{"ng-repeat" => "note in ctrl.tag.notes | filter: ctrl.filterNotes", - "ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"} - .name{"ng-if" => "note.title"} - {{note.title}} - .note-preview - {{note.text}} - .date {{(note.created_at | appDateTime) || 'Now'}} + %div{"infinite-scroll" => "ctrl.paginate()", "can-load" => "true", "threshold" => "200"} + .note{"ng-repeat" => "note in ctrl.tag.notes | limitTo:ctrl.notesToDisplay | filter: ctrl.filterNotes", + "ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"} + .name{"ng-if" => "note.title"} + {{note.title}} + .note-preview + {{note.text}} + .date {{(note.created_at | appDateTime) || 'Now'}} diff --git a/app/assets/templates/frontend/tags.html.haml b/app/assets/templates/frontend/tags.html.haml index 69e96b946..abdc92dc5 100644 --- a/app/assets/templates/frontend/tags.html.haml +++ b/app/assets/templates/frontend/tags.html.haml @@ -13,5 +13,5 @@ %input.title{"ng-disabled" => "tag != ctrl.selectedTag", "ng-model" => "tag.title", "ng-keyup" => "$event.keyCode == 13 && ctrl.saveTag($event, tag)", "mb-autofocus" => "true", "should-focus" => "ctrl.newTag", - "ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-focus" => "ctrl.onTagTitleFocus(tag)"} + "ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-focus" => "ctrl.onTagTitleFocus(tag)", "ng-blur" => "ctrl.saveTag($event, tag)"} .count {{ctrl.noteCount(tag)}} diff --git a/app/assets/templates/services/.keep b/app/assets/templates/services/.keep deleted file mode 100644 index e69de29bb..000000000