Merge pull request #58 from standardnotes/sync-provider-destruction
Sync refactor
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
178
app/assets/javascripts/app/services/authManager.js
Normal file
178
app/assets/javascripts/app/services/authManager.js
Normal file
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
]);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
6
app/assets/javascripts/app/services/filters/startFrom.js
Normal file
6
app/assets/javascripts/app/services/filters/startFrom.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Start from filter
|
||||
angular.module('app.frontend').filter('startFrom', function() {
|
||||
return function(input, start) {
|
||||
return input.slice(start);
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
245
app/assets/javascripts/app/services/syncManager.js
Normal file
245
app/assets/javascripts/app/services/syncManager.js
Normal file
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
.panel.panel-default.panel-right.account-data-menu
|
||||
.panel-body.large-padding
|
||||
%div{"ng-if" => "!user"}
|
||||
%p Enter your <a href="https://standardnotes.org" target="_blank">Standard File</a> 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 <strong>must</strong> 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
|
||||
@@ -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
|
||||
@@ -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)"}
|
||||
|
||||
@@ -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 <a href="https://standardfile.org" target="_blank">Standard File</a> 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 <strong>must</strong> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
Reference in New Issue
Block a user