Merge pull request #209 from standardnotes/sfv3

2.2
This commit is contained in:
Mo Bitar
2018-06-09 16:25:57 -05:00
committed by GitHub
41 changed files with 5399 additions and 6247 deletions

View File

@@ -1,3 +1,9 @@
{
"presets": ["env"]
"presets": ["env"],
"plugins": [
["transform-runtime", {
"polyfill": false,
"regenerator": true
}]
]
}

View File

@@ -82,6 +82,7 @@ module.exports = function(grunt) {
lib: {
src: [
'node_modules/standard-file-js/dist/regenerator.js',
'node_modules/standard-file-js/dist/sfjs.js',
'vendor/assets/bower_components/angular/angular.js',
'vendor/assets/javascripts/lodash/lodash.custom.min.js'
@@ -160,6 +161,9 @@ module.exports = function(grunt) {
grunt.loadNpmTasks('grunt-babel');
grunt.loadNpmTasks('grunt-browserify');
// grunt.registerTask('default', ['haml', 'ngtemplates', 'sass', 'concat:app',
// 'concat:lib', 'concat:dist', 'concat:css', 'babel', 'browserify', 'uglify']);
grunt.registerTask('default', ['haml', 'ngtemplates', 'sass', 'concat:app', 'babel', 'browserify',
'concat:lib', 'concat:dist', 'ngAnnotate', 'concat:css', 'uglify']);
};

View File

@@ -229,15 +229,11 @@ angular.module('app')
if(success) {
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
this.saveError = false;
this.syncTakingTooLong = false;
this.showAllChangesSavedStatus();
}.bind(this), 200)
} else {
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
this.saveError = true;
this.syncTakingTooLong = false;
this.showErrorStatus();
}.bind(this), 200)
}
@@ -277,6 +273,9 @@ angular.module('app')
}
this.showAllChangesSavedStatus = function() {
this.saveError = false;
this.syncTakingTooLong = false;
var status = "All changes saved";
if(authManager.offline()) {
status += " (offline)";
@@ -285,7 +284,9 @@ angular.module('app')
}
this.showErrorStatus = function() {
this.noteStatus = $sce.trustAsHtml("Error syncing<br>(changes saved offline)")
this.saveError = true;
this.syncTakingTooLong = false;
this.noteStatus = $sce.trustAsHtml("<span class='error bold'>Sync Unreachable</span><br>All changes saved offline")
}
this.contentChanged = function() {
@@ -325,19 +326,16 @@ angular.module('app')
this.togglePin = function() {
this.note.setAppDataItem("pinned", !this.note.pinned);
this.note.setDirty(true);
this.changesMade();
}
this.toggleLockNote = function() {
this.note.setAppDataItem("locked", !this.note.locked);
this.note.setDirty(true);
this.changesMade();
}
this.toggleArchiveNote = function() {
this.note.setAppDataItem("archived", !this.note.archived);
this.note.setDirty(true);
this.changesMade(true);
$rootScope.$broadcast("noteArchived");
}

View File

@@ -25,142 +25,149 @@ angular.module('app')
.controller('FooterCtrl', function ($rootScope, authManager, modelManager, $timeout, dbManager,
syncManager, storageManager, passcodeManager, componentManager, singletonManager, nativeExtManager) {
$rootScope.$on("reload-ext-data", () => {
if(this.reloadInProgress) { return; }
this.reloadInProgress = true;
this.securityUpdateAvailable = authManager.checkForSecurityUpdate();
$rootScope.$on("security-update-status-changed", () => {
this.securityUpdateAvailable = authManager.securityUpdateAvailable;
})
// A reload occurs when the extensions manager window is opened. We can close it after a delay
let extWindow = this.rooms.find((room) => {return room.package_info.identifier == nativeExtManager.extensionsManagerIdentifier});
if(!extWindow) {
return;
this.openSecurityUpdate = function() {
authManager.presentPasswordWizard("upgrade-security");
}
this.selectRoom(extWindow);
$rootScope.$on("reload-ext-data", () => {
if(this.reloadInProgress) { return; }
this.reloadInProgress = true;
$timeout(() => {
this.selectRoom(extWindow);
this.reloadInProgress = false;
$rootScope.$broadcast("ext-reload-complete");
}, 2000)
});
this.getUser = function() {
return authManager.user;
}
this.updateOfflineStatus = function() {
this.offline = authManager.offline();
}
this.updateOfflineStatus();
if(this.offline && !passcodeManager.hasPasscode()) {
this.showAccountMenu = true;
}
this.findErrors = function() {
this.error = syncManager.syncStatus.error;
}
this.findErrors();
this.onAuthSuccess = function() {
this.showAccountMenu = false;
}.bind(this)
this.accountMenuPressed = function() {
this.showAccountMenu = !this.showAccountMenu;
this.closeAllRooms();
}
this.closeAccountMenu = () => {
this.showAccountMenu = false;
}
this.hasPasscode = function() {
return passcodeManager.hasPasscode();
}
this.lockApp = function() {
$rootScope.lockApplication();
}
this.refreshData = function() {
this.isRefreshing = true;
syncManager.sync((response) => {
$timeout(function(){
this.isRefreshing = false;
}.bind(this), 200)
if(response && response.error) {
alert("There was an error syncing. Please try again. If all else fails, log out and log back in.");
} else {
this.syncUpdated();
// A reload occurs when the extensions manager window is opened. We can close it after a delay
let extWindow = this.rooms.find((room) => {return room.package_info.identifier == nativeExtManager.extensionsManagerIdentifier});
if(!extWindow) {
return;
}
}, {force: true}, "refreshData");
}
this.syncUpdated = function() {
this.lastSyncDate = new Date();
}
this.selectRoom(extWindow);
$rootScope.$on("new-update-available", function(version){
$timeout(function(){
// timeout calls apply() which is needed
this.onNewUpdateAvailable();
}.bind(this))
}.bind(this))
$timeout(() => {
this.selectRoom(extWindow);
this.reloadInProgress = false;
$rootScope.$broadcast("ext-reload-complete");
}, 2000)
});
this.onNewUpdateAvailable = function() {
this.newUpdateAvailable = true;
}
this.clickedNewUpdateAnnouncement = function() {
this.newUpdateAvailable = false;
alert("A new update is ready to install. Please use the top-level 'Updates' menu to manage installation.")
}
/* Rooms */
this.componentManager = componentManager;
this.rooms = [];
modelManager.addItemSyncObserver("room-bar", "SN|Component", (allItems, validItems, deletedItems, source) => {
var incomingRooms = allItems.filter((candidate) => {return candidate.area == "rooms"});
this.rooms = _.uniq(this.rooms.concat(incomingRooms)).filter((candidate) => {return !candidate.deleted});
});
componentManager.registerHandler({identifier: "roomBar", areas: ["rooms", "modal"], activationHandler: (component) => {
// RIP: There used to be code here that checked if component.active was true, and if so, displayed the component.
// However, we no longer want to persist active state for footer extensions. If you open Extensions on one computer,
// it shouldn't open on another computer. Active state should only be persisted for persistent extensions, like Folders.
}, actionHandler: (component, action, data) => {
if(action == "set-size") {
component.setLastSize(data);
this.getUser = function() {
return authManager.user;
}
}, focusHandler: (component, focused) => {
if(component.isEditor() && focused) {
this.updateOfflineStatus = function() {
this.offline = authManager.offline();
}
this.updateOfflineStatus();
if(this.offline && !passcodeManager.hasPasscode()) {
this.showAccountMenu = true;
}
this.findErrors = function() {
this.error = syncManager.syncStatus.error;
}
this.findErrors();
this.onAuthSuccess = function() {
this.showAccountMenu = false;
}.bind(this)
this.accountMenuPressed = function() {
this.showAccountMenu = !this.showAccountMenu;
this.closeAllRooms();
}
this.closeAccountMenu = () => {
this.showAccountMenu = false;
}
this.hasPasscode = function() {
return passcodeManager.hasPasscode();
}
this.lockApp = function() {
$rootScope.lockApplication();
}
this.refreshData = function() {
this.isRefreshing = true;
syncManager.sync((response) => {
$timeout(function(){
this.isRefreshing = false;
}.bind(this), 200)
if(response && response.error) {
alert("There was an error syncing. Please try again. If all else fails, log out and log back in.");
} else {
this.syncUpdated();
}
}, {force: true}, "refreshData");
}
this.syncUpdated = function() {
this.lastSyncDate = new Date();
}
$rootScope.$on("new-update-available", function(version){
$timeout(function(){
// timeout calls apply() which is needed
this.onNewUpdateAvailable();
}.bind(this))
}.bind(this))
this.onNewUpdateAvailable = function() {
this.newUpdateAvailable = true;
}
this.clickedNewUpdateAnnouncement = function() {
this.newUpdateAvailable = false;
alert("A new update is ready to install. Please use the top-level 'Updates' menu to manage installation.")
}
/* Rooms */
this.componentManager = componentManager;
this.rooms = [];
modelManager.addItemSyncObserver("room-bar", "SN|Component", (allItems, validItems, deletedItems, source) => {
var incomingRooms = allItems.filter((candidate) => {return candidate.area == "rooms"});
this.rooms = _.uniq(this.rooms.concat(incomingRooms)).filter((candidate) => {return !candidate.deleted});
});
componentManager.registerHandler({identifier: "roomBar", areas: ["rooms", "modal"], activationHandler: (component) => {
// RIP: There used to be code here that checked if component.active was true, and if so, displayed the component.
// However, we no longer want to persist active state for footer extensions. If you open Extensions on one computer,
// it shouldn't open on another computer. Active state should only be persisted for persistent extensions, like Folders.
}, actionHandler: (component, action, data) => {
if(action == "set-size") {
component.setLastSize(data);
}
}, focusHandler: (component, focused) => {
if(component.isEditor() && focused) {
this.closeAllRooms();
this.closeAccountMenu();
}
}});
$rootScope.$on("editorFocused", () => {
this.closeAllRooms();
this.closeAccountMenu();
}
}});
})
$rootScope.$on("editorFocused", () => {
this.closeAllRooms();
this.closeAccountMenu();
})
this.onRoomDismiss = function(room) {
room.showRoom = false;
}
this.closeAllRooms = function() {
for(var room of this.rooms) {
this.onRoomDismiss = function(room) {
room.showRoom = false;
}
}
this.selectRoom = function(room) {
room.showRoom = !room.showRoom;
}
this.closeAllRooms = function() {
for(var room of this.rooms) {
room.showRoom = false;
}
}
this.selectRoom = function(room) {
room.showRoom = !room.showRoom;
}
});

View File

@@ -36,7 +36,8 @@ angular.module('app')
function load() {
// pass keys to storageManager to decrypt storage
storageManager.setKeys(passcodeManager.keys());
// Update: Wait, why? passcodeManager already handles this.
// storageManager.setKeys(passcodeManager.keys());
openDatabase();
// Retrieve local data and begin sycing timer
@@ -285,7 +286,7 @@ angular.module('app')
})
}
} else {
authManager.login(server, email, pw, false, {}, function(response){
authManager.login(server, email, pw, false, false, {}, function(response){
window.location.reload();
})
}

View File

@@ -41,7 +41,14 @@ angular.module('app')
this.loadPreferences = function() {
let prevSortValue = this.sortBy;
this.sortBy = authManager.getUserPrefValue("sortBy", "created_at");
if(this.sortBy == "updated_at") {
// use client_updated_at instead
this.sortBy = "client_updated_at";
}
if(prevSortValue && prevSortValue != this.sortBy) {
$timeout(() => {
this.selectFirstNote();
@@ -120,7 +127,7 @@ angular.module('app')
var base = "";
if(this.sortBy == "created_at") {
base += " Date Added";
} else if(this.sortBy == "updated_at") {
} else if(this.sortBy == "client_updated_at") {
base += " Date Modifed";
} else if(this.sortBy == "title") {
base += " Title";
@@ -274,7 +281,7 @@ angular.module('app')
}
this.selectedSortByUpdated = function() {
this.setSortBy("updated_at");
this.setSortBy("client_updated_at");
this.sortDescending = true;
}

View File

@@ -9,12 +9,14 @@ class AccountMenu {
};
}
controller($scope, $rootScope, authManager, modelManager, syncManager, dbManager, passcodeManager, $timeout, storageManager) {
controller($scope, $rootScope, authManager, modelManager, syncManager, dbManager, passcodeManager,
$timeout, storageManager, $compile, archiveManager) {
'ngInject';
$scope.formData = {mergeLocal: true, url: syncManager.serverURL, ephemeral: false};
$scope.user = authManager.user;
$scope.server = syncManager.serverURL;
$scope.securityUpdateAvailable = authManager.checkForSecurityUpdate();
$scope.close = function() {
$timeout(() => {
@@ -29,63 +31,6 @@ class AccountMenu {
$scope.canAddPasscode = !authManager.isEphemeralSession();
$scope.syncStatus = syncManager.syncStatus;
$scope.newPasswordData = {};
$scope.showPasswordChangeForm = function() {
$scope.newPasswordData.showForm = true;
}
$scope.submitPasswordChange = function() {
let newPass = $scope.newPasswordData.newPassword;
if(!newPass || newPass.length == 0) {
return;
}
if(newPass != $scope.newPasswordData.newPasswordConfirmation) {
alert("Your new password does not match its confirmation.");
$scope.newPasswordData.status = null;
return;
}
var email = $scope.user.email;
if(!email) {
alert("We don't have your email stored. Please log out then log back in to fix this issue.");
$scope.newPasswordData.status = null;
return;
}
$scope.newPasswordData.status = "Generating New Keys...";
$scope.newPasswordData.showForm = false;
// perform a sync beforehand to pull in any last minutes changes before we change the encryption key (and thus cant decrypt new changes)
syncManager.sync(function(response){
authManager.changePassword(email, newPass, function(response){
if(response.error) {
alert("There was an error changing your password. Please try again.");
$scope.newPasswordData.status = null;
return;
}
// re-encrypt all items
$scope.newPasswordData.status = "Re-encrypting all items with your new key...";
modelManager.setAllItemsDirty();
syncManager.sync(function(response){
if(response.error) {
alert("There was an error re-encrypting your items. Your password was changed, but not all your items were properly re-encrypted and synced. You should try syncing again. If all else fails, you should restore your notes from backup.")
return;
}
$scope.newPasswordData.status = "Successfully changed password and re-encrypted all items.";
$timeout(function(){
alert("Your password has been changed, and your items successfully re-encrypted and synced. You must sign out of all other signed in applications and sign in again, or else you may corrupt your data.")
$scope.newPasswordData = {};
}, 1000)
});
})
}, null, "submitPasswordChange")
}
$scope.submitMfaForm = function() {
var params = {};
@@ -113,10 +58,13 @@ class AccountMenu {
$scope.formData.status = "Generating Login Keys...";
$timeout(function(){
authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password, $scope.formData.ephemeral, extraParams,
authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password,
$scope.formData.ephemeral, $scope.formData.strictSignin, extraParams,
(response) => {
if(!response || response.error) {
syncManager.unlockSyncing();
$scope.formData.status = null;
var error = response ? response.error : {message: "An unknown error occured."}
@@ -133,8 +81,8 @@ class AccountMenu {
$timeout(() => {
$scope.formData.showLogin = true;
$scope.formData.mfa = null;
if(error.message) { alert(error.message); }
})
alert(error.message);
}
}
@@ -204,6 +152,13 @@ class AccountMenu {
}
}
$scope.openPasswordWizard = function(type) {
// Close the account menu
$scope.close();
authManager.presentPasswordWizard(type);
}
// Allows indexeddb unencrypted logs to be deleted
// clearAllModels will remove data from backing store, but not from working memory
// See: https://github.com/standardnotes/desktop/issues/131
@@ -253,7 +208,7 @@ class AccountMenu {
var message = `Import complete. ${errorCount} items were not imported because there was an error decrypting them. Make sure the password is correct and try again.`;
alert(message);
} else {
alert("Your data was successfully imported.")
alert("Your data has been successfully imported.")
}
}
}, 10);
@@ -291,9 +246,9 @@ class AccountMenu {
var onDataReady = function(errorCount) {
var items = modelManager.mapResponseItemsToLocalModels(data.items, ModelManager.MappingSourceFileImport);
items.forEach(function(item){
item.setDirty(true);
item.setDirty(true, true);
item.deleted = false;
item.markAllReferencesDirty();
item.markAllReferencesDirty(true);
// We don't want to activate any components during import process in case of exceptions
// breaking up the import proccess
@@ -308,26 +263,28 @@ class AccountMenu {
}.bind(this)
if(data.auth_params) {
SFJS.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){
SFJS.crypto.computeEncryptionKeysForUser(password, data.auth_params).then((keys) => {
try {
SFItemTransformer.decryptMultipleItems(data.items, keys, false); /* throws = false as we don't want to interrupt all decryption if just one fails */
// 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;
});
SFJS.itemTransformer.decryptMultipleItems(data.items, keys, false) /* throws = false as we don't want to interrupt all decryption if just one fails */
.then(() => {
// 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;
});
var errorCount = 0;
// Don't import items that didn't decrypt properly
data.items = data.items.filter(function(item){
if(item.errorDecrypting) {
errorCount++;
return false;
}
return true;
var errorCount = 0;
// Don't import items that didn't decrypt properly
data.items = data.items.filter(function(item){
if(item.errorDecrypting) {
errorCount++;
return false;
}
return true;
})
onDataReady(errorCount);
})
onDataReady(errorCount);
}
catch (e) {
console.log("Error decrypting", e);
@@ -335,7 +292,7 @@ class AccountMenu {
callback(null);
return;
}
}.bind(this));
});
} else {
onDataReady();
}
@@ -345,166 +302,10 @@ class AccountMenu {
Export
*/
function loadZip(callback) {
if(window.zip) {
callback();
return;
}
var scriptTag = document.createElement('script');
scriptTag.src = "/assets/zip/zip.js";
scriptTag.async = false;
var headTag = document.getElementsByTagName('head')[0];
headTag.appendChild(scriptTag);
scriptTag.onload = function() {
zip.workerScriptsPath = "assets/zip/";
callback();
}
}
function downloadZippedNotes(notes) {
loadZip(function(){
zip.createWriter(new zip.BlobWriter("application/zip"), function(zipWriter) {
var index = 0;
function nextFile() {
var note = notes[index];
var blob = new Blob([note.text], {type: 'text/plain'});
zipWriter.add(`${note.title}-${note.uuid}.txt`, new zip.BlobReader(blob), function() {
index++;
if(index < notes.length) {
nextFile();
} else {
zipWriter.close(function(blob) {
downloadData(blob, `Notes Txt Archive - ${new Date()}.zip`)
zipWriter = null;
});
}
});
}
nextFile();
}, onerror);
})
}
var textFile = null;
function hrefForData(data) {
// 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;
}
function downloadData(data, fileName) {
var link = document.createElement('a');
link.setAttribute('download', fileName);
link.href = hrefForData(data);
document.body.appendChild(link);
link.click();
link.remove();
}
$scope.downloadDataArchive = function() {
// download in Standard File format
var keys, authParams, protocolVersion;
if($scope.archiveFormData.encrypted) {
if(authManager.offline() && passcodeManager.hasPasscode()) {
keys = passcodeManager.keys();
authParams = passcodeManager.passcodeAuthParams();
protocolVersion = authParams.version;
} else {
keys = authManager.keys();
authParams = authManager.getAuthParams();
protocolVersion = authManager.protocolVersion();
}
}
var data = $scope.itemsData(keys, authParams, protocolVersion);
downloadData(data, `SN Archive - ${new Date()}.txt`);
// download as zipped plain text files
if(!keys) {
var notes = modelManager.allItemsMatchingTypes(["Note"]);
downloadZippedNotes(notes);
}
archiveManager.downloadBackup($scope.archiveFormData.encrypted);
}
$scope.itemsData = function(keys, authParams, protocolVersion) {
let data = modelManager.getAllItemsJSONData(keys, authParams, protocolVersion);
let blobData = new Blob([data], {type: 'text/json'});
return blobData;
}
// Advanced
$scope.reencryptPressed = function() {
if(!confirm("Are you sure you want to re-encrypt and sync all your items? This is useful when updates are made to our encryption specification. You should have been instructed to come here from our website.")) {
return;
}
if(!confirm("It is highly recommended that you download a backup of your data before proceeding. Press cancel to go back. Note that this procedure can take some time, depending on the number of items you have. Do not close the app during process.")) {
return;
}
modelManager.setAllItemsDirty();
syncManager.sync(function(response){
if(response.error) {
alert("There was an error re-encrypting your items. You should try syncing again. If all else fails, you should restore your notes from backup.")
return;
}
$timeout(function(){
alert("Your items have been successfully re-encrypted and synced. You must sign out of all other signed in applications (mobile, desktop, web) and sign in again, or else you may corrupt your data.")
$scope.newPasswordData = {};
}, 1000)
}, null, "reencryptPressed");
}
// 002 Update
$scope.securityUpdateAvailable = function() {
var keys = authManager.keys()
return keys && !keys.ak;
}
$scope.clickedSecurityUpdate = function() {
if(!$scope.securityUpdateData) {
$scope.securityUpdateData = {};
}
$scope.securityUpdateData.showForm = true;
}
$scope.submitSecurityUpdateForm = function() {
$scope.securityUpdateData.processing = true;
var authParams = authManager.getAuthParams();
SFJS.crypto.computeEncryptionKeysForUser(_.merge({password: $scope.securityUpdateData.password}, authParams), function(keys){
if(keys.mk !== authManager.keys().mk) {
alert("Invalid password. Please try again.");
$timeout(function(){
$scope.securityUpdateData.processing = false;
})
return;
}
authManager.saveKeys(keys);
});
}
/*
Encryption Status
*/

View File

@@ -71,7 +71,7 @@ class ActionsMenu {
this.executeAction(subaction, extension, parentAction);
$event.stopPropagation();
},
title: subaction.label,
label: subaction.label,
subtitle: subaction.desc,
spinnerClass: subaction.running ? 'info' : null
}

View File

@@ -0,0 +1,38 @@
class InputModal {
constructor() {
this.restrict = "E";
this.templateUrl = "directives/input-modal.html";
this.scope = {
type: "=",
title: "=",
message: "=",
placeholder: "=",
callback: "&"
};
}
link($scope, el, attrs) {
$scope.el = el;
}
controller($scope, modelManager, archiveManager, authManager, syncManager, $timeout) {
'ngInject';
$scope.formData = {};
$scope.dismiss = function() {
$scope.el.remove();
$scope.$destroy();
}
$scope.submit = function() {
$scope.callback()($scope.formData.input);
$scope.dismiss();
}
}
}
angular.module('app').directive('inputModal', () => new InputModal);

View File

@@ -6,7 +6,7 @@ class MenuRow {
this.templateUrl = "directives/menu-row.html";
this.scope = {
circle: "=",
title: "=",
label: "=",
subtite: "=",
hasButton: "=",
buttonText: "=",
@@ -14,7 +14,8 @@ class MenuRow {
buttonAction: "&",
spinnerClass: "=",
subRows: "=",
faded: "="
faded: "=",
desc: "="
};
}

View File

@@ -0,0 +1,227 @@
class PasswordWizard {
constructor() {
this.restrict = "E";
this.templateUrl = "directives/password-wizard.html";
this.scope = {
type: "="
};
}
link($scope, el, attrs) {
$scope.el = el;
}
controller($scope, modelManager, archiveManager, authManager, syncManager, $timeout) {
'ngInject';
$scope.dismiss = function() {
$scope.el.remove();
$scope.$destroy();
}
$scope.syncStatus = syncManager.syncStatus;
$scope.formData = {};
const IntroStep = 0;
const BackupStep = 1;
const SignoutStep = 2;
const PasswordStep = 3;
const SyncStep = 4;
const FinishStep = 5;
let DefaultContinueTitle = "Continue";
$scope.continueTitle = DefaultContinueTitle;
$scope.step = IntroStep;
$scope.titleForStep = function(step) {
switch (step) {
case BackupStep:
return "Download a backup of your data";
case SignoutStep:
return "Sign out of all your devices";
case PasswordStep:
return $scope.changePassword ? "Password information" : "Enter your current password";
case SyncStep:
return "Encrypt and sync data with new keys";
case FinishStep:
return "Sign back in to your devices";
default:
return null;
}
}
$scope.configure = function() {
if($scope.type == "change-pw") {
$scope.title = "Change Password";
$scope.changePassword = true;
} else if($scope.type == "upgrade-security") {
$scope.title = "Security Update";
$scope.securityUpdate = true;
}
}();
$scope.continue = function() {
if($scope.step == FinishStep) {
$scope.dismiss();
return;
}
let next = () => {
$scope.step += 1;
$scope.initializeStep($scope.step);
}
var preprocessor = $scope.preprocessorForStep($scope.step);
if(preprocessor) {
preprocessor(() => {
next();
})
} else {
next();
}
}
$scope.downloadBackup = function(encrypted) {
archiveManager.downloadBackup(encrypted);
}
$scope.preprocessorForStep = function(step) {
if(step == PasswordStep) {
return (callback) => {
$scope.showSpinner = true;
$scope.continueTitle = "Generating Keys...";
$timeout(() => {
$scope.validateCurrentPassword((success) => {
$scope.showSpinner = false;
$scope.continueTitle = DefaultContinueTitle;
if(success) {
callback();
}
});
})
}
}
}
let FailedSyncMessage = "There was an error re-encrypting your items. Your password was changed, but not all your items were properly re-encrypted and synced. You should try syncing again. If all else fails, you should restore your notes from backup.";
$scope.initializeStep = function(step) {
if(step == SyncStep) {
$scope.lockContinue = true;
$scope.formData.status = "Processing encryption keys...";
$scope.formData.processing = true;
$scope.processPasswordChange((passwordSuccess) => {
$scope.formData.statusError = !passwordSuccess;
$scope.formData.processing = passwordSuccess;
if(passwordSuccess) {
$scope.formData.status = "Encrypting and syncing data with new keys...";
$scope.resyncData((syncSuccess) => {
$scope.formData.statusError = !syncSuccess;
$scope.formData.processing = !syncSuccess;
if(syncSuccess) {
$scope.lockContinue = false;
if($scope.changePassword) {
$scope.formData.status = "Successfully changed password and synced all items.";
} else if($scope.securityUpdate) {
$scope.formData.status = "Successfully performed security update and synced all items.";
}
} else {
$scope.formData.status = FailedSyncMessage;
}
})
} else {
$scope.formData.status = "Unable to process your password. Please try again.";
}
})
}
else if(step == FinishStep) {
$scope.continueTitle = "Finish";
}
}
$scope.validateCurrentPassword = function(callback) {
let currentPassword = $scope.formData.currentPassword;
let newPass = $scope.securityUpdate ? currentPassword : $scope.formData.newPassword;
if($scope.changePassword) {
if(!newPass || newPass.length == 0) {
callback(false);
return;
}
if(newPass != $scope.formData.newPasswordConfirmation) {
alert("Your new password does not match its confirmation.");
$scope.formData.status = null;
callback(false);
return;
}
}
if(!authManager.user.email) {
alert("We don't have your email stored. Please log out then log back in to fix this issue.");
$scope.formData.status = null;
callback(false);
return;
}
// Ensure value for current password matches what's saved
let authParams = authManager.getAuthParams();
let password = $scope.formData.currentPassword;
SFJS.crypto.computeEncryptionKeysForUser(password, authParams).then((keys) => {
let success = keys.mk === authManager.keys().mk;
if(success) {
this.currentServerPw = keys.pw;
} else {
alert("The current password you entered is not correct. Please try again.");
}
$timeout(() => callback(success));
});
}
$scope.resyncData = function(callback) {
modelManager.setAllItemsDirty();
syncManager.sync((response) => {
if(response.error) {
alert(FailedSyncMessage)
$timeout(() => callback(false));
} else {
$timeout(() => callback(true));
}
});
}
$scope.processPasswordChange = function(callback) {
let newUserPassword = $scope.securityUpdate ? $scope.formData.currentPassword : $scope.formData.newPassword;
let currentServerPw = this.currentServerPw;
SFJS.crypto.generateInitialKeysAndAuthParamsForUser(authManager.user.email, newUserPassword).then((results) => {
let newKeys = results.keys;
let newAuthParams = results.authParams;
// perform a sync beforehand to pull in any last minutes changes before we change the encryption key (and thus cant decrypt new changes)
syncManager.sync((response) => {
authManager.changePassword(currentServerPw, newKeys, newAuthParams, (response) => {
if(response.error) {
alert(response.error.message ? response.error.message : "There was an error changing your password. Please try again.");
$timeout(() => callback(false));
} else {
$timeout(() => callback(true));
}
})
}, null, "submitPasswordChange")
});
}
}
}
angular.module('app').directive('passwordWizard', () => new PasswordWizard);

View File

@@ -9,7 +9,7 @@ class Item {
this.observers = [];
if(!this.uuid) {
this.uuid = SFJS.crypto.generateUUID();
this.uuid = SFJS.crypto.generateUUIDSync();
}
}
@@ -48,6 +48,9 @@ class Item {
this.updated_at = new Date();
}
// Allows the getter to be re-invoked
this._client_updated_at = null;
if(json.content) {
this.mapContentToLocalProperties(this.contentObject);
} else if(json.deleted == true) {
@@ -68,7 +71,7 @@ class Item {
// Subclasses can override
}
setDirty(dirty) {
setDirty(dirty, dontUpdateClientDate) {
this.dirty = dirty;
// Allows the syncManager to check if an item has been marked dirty after a sync has been started
@@ -81,14 +84,22 @@ class Item {
this.dirtyCount = 0;
}
if(dirty && !dontUpdateClientDate) {
// Set the client modified date to now if marking the item as dirty
this.client_updated_at = new Date();
} else if(!this.hasRawClientUpdatedAtValue()) {
// copy updated_at
this.client_updated_at = new Date(this.updated_at);
}
if(dirty) {
this.notifyObserversOfChange();
}
}
markAllReferencesDirty() {
markAllReferencesDirty(dontUpdateClientDate) {
this.allReferencedObjects().forEach(function(reference){
reference.setDirty(true);
reference.setDirty(true, dontUpdateClientDate);
})
}
@@ -214,6 +225,28 @@ class Item {
return this.getAppDataItem("locked");
}
hasRawClientUpdatedAtValue() {
return this.getAppDataItem("client_updated_at") != null;
}
get client_updated_at() {
if(!this._client_updated_at) {
var saved = this.getAppDataItem("client_updated_at");
if(saved) {
this._client_updated_at = new Date(saved);
} else {
this._client_updated_at = new Date(this.updated_at);
}
}
return this._client_updated_at;
}
set client_updated_at(date) {
this._client_updated_at = date;
this.setAppDataItem("client_updated_at", date);
}
/*
During sync conflicts, when determing whether to create a duplicate for an item, we can omit keys that have no
meaningful weight and can be ignored. For example, if one component has active = true and another component has active = false,
@@ -245,7 +278,7 @@ class Item {
}
updatedAtString() {
return this.dateToLocalizedString(this.updated_at);
return this.dateToLocalizedString(this.client_updated_at);
}
dateToLocalizedString(date) {

View File

@@ -3,43 +3,44 @@ class ItemParams {
constructor(item, keys, version) {
this.item = item;
this.keys = keys;
this.version = version || "002";
this.version = version || SFJS.version();
}
paramsForExportFile(includeDeleted) {
async paramsForExportFile(includeDeleted) {
this.additionalFields = ["updated_at"];
this.forExportFile = true;
if(includeDeleted) {
return this.__params();
} else {
return _.omit(this.__params(), ["deleted"]);
var result = await this.__params();
return _.omit(result, ["deleted"]);
}
}
paramsForExtension() {
async paramsForExtension() {
return this.paramsForExportFile();
}
paramsForLocalStorage() {
async paramsForLocalStorage() {
this.additionalFields = ["updated_at", "dirty", "errorDecrypting"];
this.forExportFile = true;
return this.__params();
}
paramsForSync() {
async paramsForSync() {
return this.__params();
}
__params() {
async __params() {
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.item.errorDecrypting) {
// Items should always be encrypted for export files. Only respect item.doNotEncrypt for remote sync params;
// Items should always be encrypted for export files. Only respect item.doNotEncrypt for remote sync params.
var doNotEncrypt = this.item.doNotEncrypt() && !this.forExportFile;
if(this.keys && !doNotEncrypt) {
var encryptedParams = SFItemTransformer.encryptItem(this.item, this.keys, this.version);
var encryptedParams = await SFJS.itemTransformer.encryptItem(this.item, this.keys, this.version);
_.merge(params, encryptedParams);
if(this.version !== "001") {
@@ -47,7 +48,7 @@ class ItemParams {
}
}
else {
params.content = this.forExportFile ? this.item.createContentJSONFromProperties() : "000" + SFJS.crypto.base64(JSON.stringify(this.item.createContentJSONFromProperties()));
params.content = this.forExportFile ? this.item.createContentJSONFromProperties() : "000" + await SFJS.crypto.base64(JSON.stringify(this.item.createContentJSONFromProperties()));
if(!this.forExportFile) {
params.enc_item_key = null;
params.auth_hash = null;

View File

@@ -1,10 +1,16 @@
class ActionsManager {
constructor(httpManager, modelManager, authManager, syncManager) {
this.httpManager = httpManager;
this.modelManager = modelManager;
this.authManager = authManager;
this.syncManager = syncManager;
constructor(httpManager, modelManager, authManager, syncManager, $rootScope, $compile, $timeout) {
this.httpManager = httpManager;
this.modelManager = modelManager;
this.authManager = authManager;
this.syncManager = syncManager;
this.$rootScope = $rootScope;
this.$compile = $compile;
this.$timeout = $timeout;
// Used when decrypting old items with new keys. This array is only kept in memory.
this.previousPasswords = [];
}
get extensions() {
@@ -46,47 +52,92 @@ class ActionsManager {
}
}
executeAction(action, extension, item, callback) {
async executeAction(action, extension, item, callback) {
var customCallback = function(response) {
var customCallback = (response) => {
action.running = false;
callback(response);
this.$timeout(() => {
callback(response);
})
}
action.running = true;
let decrypted = action.access_type == "decrypted";
switch (action.verb) {
case "get": {
var triedPasswords = [];
this.httpManager.getAbsolute(action.url, {}, function(response){
action.error = false;
var items = response.items || [response.item];
SFItemTransformer.decryptMultipleItems(items, this.authManager.keys());
items = this.modelManager.mapResponseItemsToLocalModels(items, ModelManager.MappingSourceRemoteActionRetrieved);
for(var item of items) {
item.setDirty(true);
let handleResponseDecryption = async (response, keys, merge) => {
var item = response.item;
await SFJS.itemTransformer.decryptItem(item, keys);
if(!item.errorDecrypting) {
if(merge) {
var items = this.modelManager.mapResponseItemsToLocalModels([item], ModelManager.MappingSourceRemoteActionRetrieved);
for(var mappedItem of items) {
mappedItem.setDirty(true);
}
this.syncManager.sync(null);
customCallback({items: items});
}.bind(this), function(response){
customCallback({item: item});
} else {
item = this.modelManager.createItem(item, true /* Dont notify observers */);
customCallback({item: item});
}
return true;
} else {
// Error decrypting
if(!response.auth_params) {
// In some cases revisions were missing auth params. Instruct the user to email us to get this remedied.
alert("We were unable to decrypt this revision using your current keys, and this revision is missing metadata that would allow us to try different keys to decrypt it. This can likely be fixed with some manual intervention. Please email hello@standardnotes.org for assistance.");
return;
}
// Try previous passwords
for(let passwordCandidate of this.previousPasswords) {
if(triedPasswords.includes(passwordCandidate)) {
continue;
}
triedPasswords.push(passwordCandidate);
var keyResults = await SFJS.crypto.computeEncryptionKeysForUser(passwordCandidate, response.auth_params);
if(!keyResults) {
continue;
}
var success = await handleResponseDecryption(response, keyResults, merge);
if(success) {
return true;
}
}
this.presentPasswordModal((password) => {
this.previousPasswords.push(password);
handleResponseDecryption(response, keys, merge);
});
return false;
}
}
switch (action.verb) {
case "get": {
this.httpManager.getAbsolute(action.url, {}, (response) => {
action.error = false;
handleResponseDecryption(response, this.authManager.keys(), true);
}, (response) => {
action.error = true;
customCallback(null);
})
break;
}
case "render": {
this.httpManager.getAbsolute(action.url, {}, function(response){
this.httpManager.getAbsolute(action.url, {}, (response) => {
action.error = false;
SFItemTransformer.decryptItem(response.item, this.authManager.keys());
var item = this.modelManager.createItem(response.item, true /* Dont notify observers */);
customCallback({item: item});
}.bind(this), function(response){
handleResponseDecryption(response, this.authManager.keys(), false);
}, (response) => {
action.error = true;
customCallback(null);
})
@@ -102,22 +153,15 @@ class ActionsManager {
}
case "post": {
var params = {};
this.outgoingParamsForItem(item, extension, decrypted).then((itemParams) => {
var params = {
items: [itemParams] // Wrap it in an array
}
if(action.all) {
var items = this.modelManager.allItemsMatchingTypes(action.content_types);
params.items = items.map(function(item){
var params = this.outgoingParamsForItem(item, extension, decrypted);
return params;
}.bind(this))
} else {
params.items = [this.outgoingParamsForItem(item, extension, decrypted)];
}
this.performPost(action, extension, params, function(response){
customCallback(response);
});
this.performPost(action, extension, params, function(response){
customCallback(response);
});
})
break;
}
@@ -130,7 +174,7 @@ class ActionsManager {
action.lastExecuted = new Date();
}
outgoingParamsForItem(item, extension, decrypted = false) {
async outgoingParamsForItem(item, extension, decrypted = false) {
var keys = this.authManager.keys();
if(decrypted) {
keys = null;
@@ -154,6 +198,17 @@ class ActionsManager {
})
}
presentPasswordModal(callback) {
var scope = this.$rootScope.$new(true);
scope.type = "password";
scope.title = "Decryption Assistance";
scope.message = "Unable to decrypt this item with your current keys. Please enter your account password at the time of this revision.";
scope.callback = callback;
var el = this.$compile( "<input-modal type='type' message='message' title='title' callback='callback'></input-modal>" )(scope);
angular.element(document.body).append(el);
}
}
angular.module('app').service('actionsManager', ActionsManager);

View File

@@ -0,0 +1,120 @@
class ArchiveManager {
constructor(passcodeManager, authManager, modelManager) {
this.passcodeManager = passcodeManager;
this.authManager = authManager;
this.modelManager = modelManager;
}
/*
Public
*/
downloadBackup(encrypted) {
// download in Standard File format
var keys, authParams, protocolVersion;
if(encrypted) {
if(this.authManager.offline() && this.passcodeManager.hasPasscode()) {
keys = this.passcodeManager.keys();
authParams = this.passcodeManager.passcodeAuthParams();
protocolVersion = authParams.version;
} else {
keys = this.authManager.keys();
authParams = this.authManager.getAuthParams();
protocolVersion = this.authManager.protocolVersion();
}
}
this.__itemsData(keys, authParams, protocolVersion).then((data) => {
this.__downloadData(data, `SN Archive - ${new Date()}.txt`);
// download as zipped plain text files
if(!keys) {
var notes = this.modelManager.allItemsMatchingTypes(["Note"]);
this.__downloadZippedNotes(notes);
}
})
}
/*
Private
*/
async __itemsData(keys, authParams, protocolVersion) {
let data = await this.modelManager.getAllItemsJSONData(keys, authParams, protocolVersion);
let blobData = new Blob([data], {type: 'text/json'});
return blobData;
}
__loadZip(callback) {
if(window.zip) {
callback();
return;
}
var scriptTag = document.createElement('script');
scriptTag.src = "/assets/zip/zip.js";
scriptTag.async = false;
var headTag = document.getElementsByTagName('head')[0];
headTag.appendChild(scriptTag);
scriptTag.onload = function() {
zip.workerScriptsPath = "assets/zip/";
callback();
}
}
__downloadZippedNotes(notes) {
this.__loadZip(() => {
zip.createWriter(new zip.BlobWriter("application/zip"), (zipWriter) => {
var index = 0;
let nextFile = () => {
var note = notes[index];
var blob = new Blob([note.text], {type: 'text/plain'});
var title = note.title.replace("/", "").replace("\\", "");
zipWriter.add(`${title}-${note.uuid}.txt`, new zip.BlobReader(blob), () => {
index++;
if(index < notes.length) {
nextFile();
} else {
zipWriter.close((blob) => {
this.__downloadData(blob, `Notes Txt Archive - ${new Date()}.zip`)
zipWriter = null;
});
}
});
}
nextFile();
}, onerror);
})
}
__hrefForData(data) {
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (this.textFile !== null) {
window.URL.revokeObjectURL(this.textFile);
}
this.textFile = window.URL.createObjectURL(data);
// returns a URL you can use as a href
return this.textFile;
}
__downloadData(data, fileName) {
var link = document.createElement('a');
link.setAttribute('download', fileName);
link.href = this.__hrefForData(data);
document.body.appendChild(link);
link.click();
link.remove();
}
}
angular.module('app').service('archiveManager', ArchiveManager);

View File

@@ -7,11 +7,11 @@ angular.module('app')
return domain;
}
this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager) {
return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager);
this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager, $compile) {
return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager, $compile);
}
function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager) {
function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager, $compile) {
this.loadInitialData = function() {
var userData = storageManager.getItem("user");
@@ -77,21 +77,15 @@ angular.module('app')
var keys = this.keys();
if(keys && keys.ak) {
// If there's no version stored, and there's an ak, it has to be 002. Newer versions would have thier version stored in authParams.
return "002";
} else {
return "001";
}
}
this.costMinimumForVersion = function(version) {
// all current versions have a min of 3000
// future versions will increase this
return 3000;
}
this.isProtocolVersionSupported = function(version) {
var supportedVersions = ["001", "002"];
return supportedVersions.includes(version);
return SFJS.supportedVersions().includes(version);
}
this.getAuthParamsForEmail = function(url, email, extraParams, callback) {
@@ -107,16 +101,11 @@ angular.module('app')
})
}
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.
return SFJS.crypto.supportsPasswordDerivationCost(cost);
}
this.login = function(url, email, password, ephemeral, strictSignin, extraParams, callback) {
this.getAuthParamsForEmail(url, email, extraParams, (authParams) => {
this.login = function(url, email, password, ephemeral, extraParams, callback) {
this.getAuthParamsForEmail(url, email, extraParams, function(authParams){
// SF3 requires a unique identifier in the auth params
authParams.identifier = email;
if(authParams.error) {
callback(authParams);
@@ -129,12 +118,27 @@ angular.module('app')
}
if(!this.isProtocolVersionSupported(authParams.version)) {
let message = "The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.org/help/security-update for more information.";
var message;
if(SFJS.isVersionNewerThanLibraryVersion(authParams.version)) {
// The user has a new account type, but is signing in to an older client.
message = "This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.";
} else {
// The user has a very old account type, which is no longer supported by this client
message = "The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.org/help/security for more information.";
}
callback({error: {message: message}});
return;
}
if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) {
if(SFJS.isProtocolVersionOutdated(authParams.version)) {
let message = `The encryption version for your account, ${authParams.version}, is outdated and requires upgrade. You may proceed with login, but are advised to follow prompts for Security Updates once inside. Please visit standardnotes.org/help/security for more information.\n\nClick 'OK' to proceed with login.`
if(!confirm(message)) {
callback({error: {}});
return;
}
}
if(!SFJS.supportsPasswordDerivationCost(authParams.pw_cost)) {
let message = "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 log in."
@@ -142,32 +146,42 @@ angular.module('app')
return;
}
var minimum = this.costMinimumForVersion(authParams.version);
var minimum = SFJS.costMinimumForVersion(authParams.version);
if(authParams.pw_cost < minimum) {
let message = "Unable to login due to insecure password parameters. Please visit standardnotes.org/help/password-upgrade for more information.";
let message = "Unable to login due to insecure password parameters. Please visit standardnotes.org/help/security for more information.";
callback({error: {message: message}});
return;
}
SFJS.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){
if(strictSignin) {
// Refuse sign in if authParams.version is anything but the latest version
var latestVersion = SFJS.version();
if(authParams.version !== latestVersion) {
let message = `Strict sign in refused server sign in parameters. The latest security version is ${latestVersion}, but your account is reported to have version ${authParams.version}. If you'd like to proceed with sign in anyway, please disable strict sign in and try again.`;
callback({error: {message: message}});
return;
}
}
SFJS.crypto.computeEncryptionKeysForUser(password, authParams).then((keys) => {
var requestUrl = url + "/auth/sign_in";
var params = _.merge({password: keys.pw, email: email}, extraParams);
httpManager.postAbsolute(requestUrl, params, function(response){
httpManager.postAbsolute(requestUrl, params, (response) => {
this.setEphemeral(ephemeral);
this.handleAuthResponse(response, email, url, authParams, keys);
this.checkForSecurityUpdate();
callback(response);
}.bind(this), function(response){
$timeout(() => callback(response));
}, (response) => {
console.error("Error logging in", response);
if(typeof response !== 'object') {
response = {error: {message: "A server error occurred while trying to sign in. Please try again."}};
}
callback(response);
})
$timeout(() => callback(response));
});
}.bind(this));
}.bind(this))
});
})
}
this.handleAuthResponse = function(response, email, url, authParams, keys) {
@@ -191,88 +205,75 @@ angular.module('app')
this.saveKeys = function(keys) {
this._keys = keys;
// Doesn't need to be saved.
// pw doesn't need to be saved.
// storageManager.setItem("pw", keys.pw);
storageManager.setItem("mk", keys.mk);
storageManager.setItem("ak", keys.ak);
}
this.register = function(url, email, password, ephemeral, callback) {
SFJS.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){
SFJS.crypto.generateInitialKeysAndAuthParamsForUser(email, password).then((results) => {
let keys = results.keys;
let authParams = results.authParams;
var requestUrl = url + "/auth";
var params = _.merge({password: keys.pw, email: email}, authParams);
httpManager.postAbsolute(requestUrl, params, function(response){
httpManager.postAbsolute(requestUrl, params, (response) => {
this.setEphemeral(ephemeral);
this.handleAuthResponse(response, email, url, authParams, keys);
callback(response);
}.bind(this), function(response){
}, (response) => {
console.error("Registration error", response);
if(typeof response !== 'object') {
response = {error: {message: "A server error occurred while trying to register. Please try again."}};
}
callback(response);
}.bind(this))
}.bind(this));
}
this.changePassword = function(email, new_password, callback) {
SFJS.crypto.generateInitialEncryptionKeysForUser({password: new_password, email: email}, function(keys, authParams){
var requestUrl = storageManager.getItem("server") + "/auth/change_pw";
var params = _.merge({new_password: keys.pw}, authParams);
httpManager.postAbsolute(requestUrl, params, function(response) {
this.handleAuthResponse(response, email, null, authParams, keys);
callback(response);
}.bind(this), function(response){
var error = response;
if(!error) {
error = {message: "Something went wrong while changing your password. Your password was not changed. Please try again."}
}
console.error("Change pw error", response);
callback({error: error});
})
}.bind(this))
});
}
this.updateAuthParams = function(authParams, callback) {
var requestUrl = storageManager.getItem("server") + "/auth/update";
var params = authParams;
httpManager.postAbsolute(requestUrl, params, function(response) {
storageManager.setItem("auth_params", JSON.stringify(authParams));
if(callback) {
callback(response);
}
}.bind(this), function(response){
var error = response;
console.error("Update error:", response);
if(callback) {
callback({error: error});
this.changePassword = function(current_server_pw, newKeys, newAuthParams, callback) {
let email = this.user.email;
let newServerPw = newKeys.pw;
var requestUrl = storageManager.getItem("server") + "/auth/change_pw";
var params = _.merge({new_password: newServerPw, current_password: current_server_pw}, newAuthParams);
httpManager.postAbsolute(requestUrl, params, (response) => {
this.handleAuthResponse(response, email, null, newAuthParams, newKeys);
callback(response);
// Allows security update status to be changed if neccessary
this.checkForSecurityUpdate();
}, (response) => {
if(typeof response !== 'object') {
response = {error: {message: "Something went wrong while changing your password. Your password was not changed. Please try again."}}
}
callback(response);
})
}
this.checkForSecurityUpdate = function() {
if(this.offline()) {
return;
return false;
}
if(this.protocolVersion() === "001") {
if(this.keys().ak) {
// upgrade to 002
var authParams = this.getAuthParams();
authParams.version = "002";
this.updateAuthParams(authParams, function(response){
if(!response.error) {
// let rest of UI load first
$timeout(function(){
alert("Your encryption version has been updated. To take full advantage of this update, please resync all your items by clicking Account -> Advanced -> Re-encrypt All Items.")
}, 750);
}
});
}
let latest = SFJS.version();
let updateAvailable = this.protocolVersion() !== latest;
if(updateAvailable !== this.securityUpdateAvailable) {
this.securityUpdateAvailable = updateAvailable;
$rootScope.$broadcast("security-update-status-changed");
}
return this.securityUpdateAvailable;
}
this.presentPasswordWizard = function(type) {
var scope = $rootScope.$new(true);
scope.type = type;
var el = $compile( "<password-wizard type='type'></password-wizard>" )(scope);
angular.element(document.body).append(el);
}
this.staticifyObject = function(object) {

View File

@@ -399,7 +399,9 @@ class ComponentManager {
for(let handler of this.handlersForArea(component.area)) {
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(component);
this.sendContextItemInReply(component, itemInContext, message);
if(itemInContext) {
this.sendContextItemInReply(component, itemInContext, message);
}
}
}
}.bind(this))
@@ -717,7 +719,7 @@ class ComponentManager {
console.log("Web|componentManager|registerComponentWindow", component);
}
component.window = componentWindow;
component.sessionKey = SFJS.crypto.generateUUID();
component.sessionKey = SFJS.crypto.generateUUIDSync();
this.sendMessageToComponent(component, {
action: "component-registered",
sessionKey: component.sessionKey,

View File

@@ -32,8 +32,11 @@ class DesktopManager {
return this.applicationDataPath;
}
/* Sending a component in its raw state is really slow for the desktop app */
convertComponentForTransmission(component) {
/*
Sending a component in its raw state is really slow for the desktop app
Keys are not passed into ItemParams, so the result is not encrypted
*/
async convertComponentForTransmission(component) {
return new ItemParams(component).paramsForExportFile(true);
}
@@ -41,14 +44,15 @@ class DesktopManager {
syncComponentsInstallation(components) {
if(!this.isDesktop) return;
var data = components.map((component) => {
Promise.all(components.map((component) => {
return this.convertComponentForTransmission(component);
})).then((data) => {
this.installationSyncHandler(data);
})
this.installationSyncHandler(data);
}
installComponent(component) {
this.installComponentHandler(this.convertComponentForTransmission(component));
async installComponent(component) {
this.installComponentHandler(await this.convertComponentForTransmission(component));
}
registerUpdateObserver(callback) {
@@ -125,7 +129,7 @@ class DesktopManager {
}
}
desktop_requestBackupFile() {
desktop_requestBackupFile(callback) {
var keys, authParams, protocolVersion;
if(this.authManager.offline() && this.passcodeManager.hasPasscode()) {
keys = this.passcodeManager.keys();
@@ -137,13 +141,14 @@ class DesktopManager {
protocolVersion = this.authManager.protocolVersion();
}
let data = this.modelManager.getAllItemsJSONData(
this.modelManager.getAllItemsJSONData(
keys,
authParams,
protocolVersion,
true /* return null on empty */
);
return data;
).then((data) => {
callback(data);
})
}
desktop_setMajorDataChangeHandler(handler) {

View File

@@ -59,7 +59,7 @@ class ModelManager {
var newItem = this.createItem(item);
newItem.uuid = SFJS.crypto.generateUUID();
newItem.uuid = SFJS.crypto.generateUUIDSync();
// Update uuids of relationships
newItem.informReferencesOfUUIDChange(item.uuid, newItem.uuid);
@@ -167,7 +167,7 @@ class ModelManager {
if(json_obj.deleted == true || unknownContentType) {
if(json_obj.deleted && json_obj.dirty) {
// Item was marked as deleted but not yet synced
// We need to create this item as usual, but just not add it to indivudal arrays
// We need to create this item as usual, but just not add it to individual arrays
// i.e add to this.items but not this.notes (so that it can be retrieved with getDirtyItems)
isDirtyItemPendingDelete = true;
} else {
@@ -380,8 +380,11 @@ class ModelManager {
}
getDirtyItems() {
// Items that have errorDecrypting should never be synced back up to the server
return this.items.filter(function(item){return item.dirty == true && !item.dummy && !item.errorDecrypting})
return this.items.filter((item) => {
// An item that has an error decrypting can be synced only if it is being deleted.
// Otherwise, we don't want to send corrupt content up to the server.
return item.dirty == true && !item.dummy && (!item.errorDecrypting || item.deleted);
})
}
clearDirtyItems(items) {
@@ -414,13 +417,13 @@ class ModelManager {
}
/* Used when changing encryption key */
setAllItemsDirty() {
setAllItemsDirty(dontUpdateClientDates = true) {
var relevantItems = this.allItems.filter(function(item){
return _.includes(this.acceptableContentTypes, item.content_type);
}.bind(this));
for(var item of relevantItems) {
item.setDirty(true);
item.setDirty(true, dontUpdateClientDates);
}
}
@@ -459,24 +462,25 @@ class ModelManager {
Archives
*/
getAllItemsJSONData(keys, authParams, protocolVersion, returnNullIfEmpty) {
var items = _.map(this.allItems, (item) => {
async getAllItemsJSONData(keys, authParams, protocolVersion, returnNullIfEmpty) {
return Promise.all(this.allItems.map((item) => {
var itemParams = new ItemParams(item, keys, protocolVersion);
return itemParams.paramsForExportFile();
});
})).then((items) => {
if(returnNullIfEmpty && items.length == 0) {
return null;
}
if(returnNullIfEmpty && items.length == 0) {
return null;
}
var data = {items: items}
var data = {items: items}
if(keys) {
// auth params are only needed when encrypted with a standard file key
data["auth_params"] = authParams;
}
if(keys) {
// auth params are only needed when encrypted with a standard file key
data["auth_params"] = authParams;
}
return JSON.stringify(data, null, 2 /* pretty print */);
})
return JSON.stringify(data, null, 2 /* pretty print */);
}

View File

@@ -26,38 +26,47 @@ angular.module('app')
return JSON.parse(storageManager.getItem("offlineParams", StorageManager.Fixed));
}
this.protocolVersion = function() {
return this._authParams && this._authParams.version;
}
this.unlock = function(passcode, callback) {
var params = this.passcodeAuthParams();
SFJS.crypto.computeEncryptionKeysForUser(_.merge({password: passcode}, params), function(keys){
SFJS.crypto.computeEncryptionKeysForUser(passcode, params).then((keys) => {
if(keys.pw !== params.hash) {
callback(false);
return;
}
this._keys = keys;
this.decryptLocalStorage(keys);
this._locked = false;
callback(true);
}.bind(this));
this._authParams = params;
this.decryptLocalStorage(keys, params).then(() => {
this._locked = false;
callback(true);
})
});
}
this.setPasscode = (passcode, callback) => {
var cost = SFJS.crypto.defaultPasswordGenerationCost();
var salt = SFJS.crypto.generateRandomKey(512);
var defaultParams = {pw_cost: cost, pw_salt: salt, version: "002"};
var uuid = SFJS.crypto.generateUUIDSync();
SFJS.crypto.computeEncryptionKeysForUser(_.merge({password: passcode}, defaultParams), function(keys) {
defaultParams.hash = keys.pw;
SFJS.crypto.generateInitialKeysAndAuthParamsForUser(uuid, passcode).then((results) => {
let keys = results.keys;
let authParams = results.authParams;
authParams.hash = keys.pw;
this._keys = keys;
this._hasPasscode = true;
this._authParams = authParams;
// Encrypting will initially clear localStorage
this.encryptLocalStorage(keys);
this.encryptLocalStorage(keys, authParams);
// After it's cleared, it's safe to write to it
storageManager.setItem("offlineParams", JSON.stringify(defaultParams), StorageManager.Fixed);
storageManager.setItem("offlineParams", JSON.stringify(authParams), StorageManager.Fixed);
callback(true);
}.bind(this));
});
}
this.changePasscode = (newPasscode, callback) => {
@@ -71,16 +80,16 @@ angular.module('app')
this._hasPasscode = false;
}
this.encryptLocalStorage = function(keys) {
storageManager.setKeys(keys);
this.encryptLocalStorage = function(keys, authParams) {
storageManager.setKeys(keys, authParams);
// Switch to Ephemeral storage, wiping Fixed storage
// Last argument is `force`, which we set to true because in the case of changing passcode
storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.FixedEncrypted, true);
}
this.decryptLocalStorage = function(keys) {
storageManager.setKeys(keys);
storageManager.decryptStorage();
this.decryptLocalStorage = async function(keys, authParams) {
storageManager.setKeys(keys, authParams);
return storageManager.decryptStorage();
}
}
});

View File

@@ -142,8 +142,9 @@ class StorageManager {
return hash;
}
setKeys(keys) {
setKeys(keys, authParams) {
this.encryptedStorageKeys = keys;
this.encryptedStorageAuthParams = authParams;
}
writeEncryptedStorageToDisk() {
@@ -152,13 +153,15 @@ class StorageManager {
encryptedStorage.storage = this.storageAsHash();
// Save new encrypted storage in Fixed storage
var params = new ItemParams(encryptedStorage, this.encryptedStorageKeys);
this.setItem("encryptedStorage", JSON.stringify(params.paramsForSync()), StorageManager.Fixed);
var params = new ItemParams(encryptedStorage, this.encryptedStorageKeys, this.encryptedStorageAuthParams.version);
params.paramsForSync().then((syncParams) => {
this.setItem("encryptedStorage", JSON.stringify(syncParams), StorageManager.Fixed);
})
}
decryptStorage() {
async decryptStorage() {
var stored = JSON.parse(this.getItem("encryptedStorage", StorageManager.Fixed));
SFItemTransformer.decryptItem(stored, this.encryptedStorageKeys);
await SFJS.itemTransformer.decryptItem(stored, this.encryptedStorageKeys);
var encryptedStorage = new EncryptedStorage(stored);
for(var key of Object.keys(encryptedStorage.storage)) {

View File

@@ -26,27 +26,51 @@ class SyncManager {
callback && callback();
return;
}
// Use null to use the latest protocol version if offline
var version = this.authManager.offline() ? null : this.authManager.protocolVersion();
var version = this.authManager.offline() ? this.passcodeManager.protocolVersion() : this.authManager.protocolVersion();
var keys = this.authManager.offline() ? this.passcodeManager.keys() : this.authManager.keys();
var params = items.map(function(item) {
Promise.all(items.map(async (item) => {
var itemParams = new ItemParams(item, keys, version);
itemParams = itemParams.paramsForLocalStorage();
itemParams = await itemParams.paramsForLocalStorage();
if(offlineOnly) {
delete itemParams.dirty;
}
return itemParams;
}.bind(this));
this.storageManager.saveModels(params, callback);
})).then((params) => {
this.storageManager.saveModels(params, callback);
})
}
loadLocalItems(callback) {
var params = this.storageManager.getAllModels(function(items){
var items = this.handleItemsResponse(items, null, ModelManager.MappingSourceLocalRetrieved);
Item.sortItemsByDate(items);
callback(items);
}.bind(this))
async loadLocalItems(callback) {
this.storageManager.getAllModels((items) => {
// break it up into chunks to make interface more responsive for large item counts
let total = items.length;
let iteration = 50;
var current = 0;
var processed = [];
var completion = () => {
Item.sortItemsByDate(processed);
callback(processed);
}
var decryptNext = async () => {
var subitems = items.slice(current, current + iteration);
var processedSubitems = await this.handleItemsResponse(subitems, null, ModelManager.MappingSourceLocalRetrieved);
processed.push(processedSubitems);
current += subitems.length;
if(current < total) {
this.$timeout(() => { decryptNext(); });
} else {
completion();
}
}
decryptNext();
})
}
syncOffline(items, callback) {
@@ -54,7 +78,7 @@ class SyncManager {
for(var item of items) {
item.updated_at = new Date();
}
this.writeItemsToLocalStorage(items, true, function(responseItems){
this.writeItemsToLocalStorage(items, true, (responseItems) => {
// delete anything needing to be deleted
for(var item of items) {
if(item.deleted) {
@@ -70,7 +94,7 @@ class SyncManager {
if(callback) {
callback({success: true});
}
}.bind(this))
})
}
@@ -201,7 +225,7 @@ class SyncManager {
this.syncLocked = false;
}
sync(callback, options = {}, source) {
async sync(callback, options = {}, source) {
if(this.syncLocked) {
console.log("Sync Locked, Returning;");
@@ -264,6 +288,12 @@ class SyncManager {
this.syncStatus.current = 0;
}
// If items are marked as dirty during a long running sync request, total isn't updated
// This happens mostly in the case of large imports and sync conflicts where duplicated items are created
if(this.syncStatus.current > this.syncStatus.total) {
this.syncStatus.total = this.syncStatus.current;
}
// when doing a sync request that returns items greater than the limit, and thus subsequent syncs are required,
// we want to keep track of all retreived items, then save to local storage only once all items have been retrieved,
// so that relationships remain intact
@@ -281,11 +311,14 @@ class SyncManager {
var params = {};
params.limit = 150;
params.items = _.map(subItems, function(item){
await Promise.all(subItems.map((item) => {
var itemParams = new ItemParams(item, keys, version);
itemParams.additionalFields = options.additionalFields;
return itemParams.paramsForSync();
}.bind(this));
})).then((itemsParams) => {
params.items = itemsParams;
})
for(var item of subItems) {
// Reset dirty counter to 0, since we're about to sync it.
@@ -300,7 +333,7 @@ class SyncManager {
this.stopCheckingIfSyncIsTakingTooLong();
}.bind(this);
var onSyncSuccess = function(response) {
var onSyncSuccess = async function(response) {
// Check to make sure any subItem hasn't been marked as dirty again while a sync was ongoing
var itemsToClearAsDirty = [];
for(var item of subItems) {
@@ -325,8 +358,7 @@ class SyncManager {
// Map retrieved items to local data
// Note that deleted items will not be returned
var retrieved
= this.handleItemsResponse(response.retrieved_items, null, ModelManager.MappingSourceRemoteRetrieved);
var retrieved = await this.handleItemsResponse(response.retrieved_items, null, ModelManager.MappingSourceRemoteRetrieved);
// Append items to master list of retrieved items for this ongoing sync operation
this.allRetreivedItems = this.allRetreivedItems.concat(retrieved);
@@ -337,8 +369,7 @@ class SyncManager {
var omitFields = ["content", "auth_hash"];
// Map saved items to local data
var saved =
this.handleItemsResponse(response.saved_items, omitFields, ModelManager.MappingSourceRemoteSaved);
var saved = await this.handleItemsResponse(response.saved_items, omitFields, ModelManager.MappingSourceRemoteSaved);
// Append items to master list of saved items for this ongoing sync operation
this.allSavedItems = this.allSavedItems.concat(saved);
@@ -380,7 +411,7 @@ class SyncManager {
) {
this.$rootScope.$broadcast("major-data-change");
}
this.callQueuedCallbacksAndCurrent(callback, response);
this.$rootScope.$broadcast("sync:completed", {retrievedItems: this.allRetreivedItems, savedItems: this.allSavedItems});
@@ -418,9 +449,9 @@ class SyncManager {
}
}
handleItemsResponse(responseItems, omitFields, source) {
async handleItemsResponse(responseItems, omitFields, source) {
var keys = this.authManager.keys() || this.passcodeManager.keys();
SFItemTransformer.decryptMultipleItems(responseItems, keys);
await SFJS.itemTransformer.decryptMultipleItems(responseItems, keys);
var items = this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields, source);
// During the decryption process, items may be marked as "errorDecrypting". If so, we want to be sure
@@ -446,7 +477,7 @@ class SyncManager {
}
}
handleUnsavedItemsResponse(unsaved) {
async handleUnsavedItemsResponse(unsaved) {
if(unsaved.length == 0) {
return;
}
@@ -454,7 +485,7 @@ class SyncManager {
console.log("Handle unsaved", unsaved);
var i = 0;
var handleNext = () => {
var handleNext = async () => {
if(i >= unsaved.length) {
// Handled all items
this.sync(null, {additionalFields: ["created_at", "updated_at"]});
@@ -463,7 +494,7 @@ class SyncManager {
var mapping = unsaved[i];
var itemResponse = mapping.item;
SFItemTransformer.decryptMultipleItems([itemResponse], this.authManager.keys());
await SFJS.itemTransformer.decryptMultipleItems([itemResponse], this.authManager.keys());
var item = this.modelManager.findItem(itemResponse.uuid);
if(!item) {

View File

@@ -61,6 +61,10 @@ $heading-height: 75px;
margin-top: 4px;
text-align: right;
color: rgba(black, 0.23);
.error {
color: #f6a200;
}
}
.editor-tags {

View File

@@ -87,12 +87,15 @@ $footer-height: 32px;
top: 0;
right: 0;
z-index: 1;
width: 8px;
width: 6px;
height: 100%;
position: absolute;
cursor: col-resize;
background-color: rgb(224, 224, 224);
background-color: rgba(black, 0.05);
opacity: 0;
border: 1px dashed rgba($blue-color, 0.15);
border-top: none;
border-bottom: none;
&.left {
left: 0;

View File

@@ -12,6 +12,10 @@
}
}
#password-wizard {
font-size: 16px;
}
.panel {
background-color: white;
}
@@ -44,6 +48,12 @@
}
}
&.auto-height {
> .content {
height: auto !important;
}
}
&.medium {
> .content {
width: 700px;
@@ -51,6 +61,13 @@
}
}
&.small {
> .content {
width: 700px;
height: 344px;
}
}
.background {
position: absolute;
z-index: -1;
@@ -64,6 +81,10 @@
padding: 0;
padding-bottom: 0;
min-width: 300px;
-webkit-box-shadow: 0px 2px 35px 0px rgba(0,0,0,0.19);
-moz-box-shadow: 0px 2px 35px 0px rgba(0,0,0,0.19);
box-shadow: 0px 2px 35px 0px rgba(0,0,0,0.19);
}
}

View File

@@ -1,5 +1,24 @@
.sn-component {
.notification {
&.unpadded {
padding: 0;
padding-bottom: 0 !important;
padding-top: 0;
}
.padded-row {
padding: 10px 12px;
}
.bordered-row {
border-bottom: 1px solid rgba(black, 0.1);
border-top: 1px solid rgba(black, 0.1);
}
}
}
.panel {

View File

@@ -70,6 +70,14 @@ $screen-md-max: ($screen-lg-min - 1) !default;
margin-top: 10px !important;
}
.mr-5 {
margin-right: 5px !important;
}
.mr-8 {
margin-right: 8px !important;
}
.faded {
opacity: 0.5;
}
@@ -82,6 +90,10 @@ $screen-md-max: ($screen-lg-min - 1) !default;
display: block !important;
}
.inline {
display: inline-block;
}
.wrap {
word-wrap: break-word;
word-break: break-all;
@@ -103,10 +115,10 @@ $screen-md-max: ($screen-lg-min - 1) !default;
font-weight: normal !important;
}
.small {
.small-text {
font-size: 10px;
}
.medium {
.medium-text {
font-size: 14px !important;
}

View File

@@ -31,10 +31,18 @@
.notification.info{"ng-if" => "formData.showRegister"}
%h2.title No Password Reset.
.text Because your notes are encrypted using your password, Standard Notes does not have a password reset option. You cannot forget your password.
.advanced-options.panel-row{"ng-if" => "formData.showAdvanced"}
.notification.unpadded.default.advanced-options.panel-row{"ng-if" => "formData.showAdvanced"}
.panel-column.stretch
%label Sync Server Domain
%input.form-control.mt-5{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'formData.url'}
%h4.title.panel-row.padded-row Advanced Options
%div.bordered-row.padded-row
%label Sync Server Domain
%input.form-control.mt-5{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'formData.url'}
%label.padded-row{"ng-if" => "formData.showLogin"}
%input{"type" => "checkbox", "ng-model" => "formData.strictSignin"}
Use strict sign in
%span
%a{"href" => "https://standardnotes.org/help/security", "target" => "_blank"} (Learn more)
.button-group.stretch.panel-row.form-submit
%button.button.info.featured{"type" => "submit"}
@@ -60,6 +68,11 @@
%div{"ng-if" => "!formData.showLogin && !formData.showRegister && !formData.mfa"}
.panel-section{"ng-if" => "user"}
.notification.danger{"ng-if" => "syncStatus.error"}
%h2.title Sync Unreachable
.text Hmm...we can't seem to sync your account. The reason: {{syncStatus.error.message}}
%p
%a{"href" => "https://standardnotes.org/help", "target" => "_blank"} Need help?
.panel-row
%h2.title.wrap {{user.email}}
.horizontal-group{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress || syncStatus.needsMoreSync", "delay" => "1000"}
@@ -68,55 +81,14 @@
{{"Syncing" + (syncStatus.total > 0 ? ":" : "")}}
%span{"ng-if" => "syncStatus.total > 0"} {{syncStatus.current}}/{{syncStatus.total}}
.subtitle.danger.panel-row{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}}
.subtitle.subtle.normal {{server}}
.panel-row
%a.panel-row.condensed{"ng-click" => "newPasswordData.changePassword = !newPasswordData.changePassword"} Change Password
.notification.warning{"ng-if" => "newPasswordData.changePassword"}
%h1.title Change Password
.text
%p Since your encryption key is based on your password, changing your password requires all your notes and tags to be re-encrypted using your new key.
%p If you have thousands of items, this can take several minutes — you must keep the application window open during this process.
%p After changing your password, you must log out of all other applications currently signed in to your account.
%p.bold It is highly recommended you download a backup of your data before proceeding.
.panel-row{"ng-if" => "!newPasswordData.status"}
.horizontal-group{"ng-if" => "!newPasswordData.showForm"}
%a.red{"ng-click" => "showPasswordChangeForm()"} Continue
%a{"ng-click" => "newPasswordData.changePassword = false; newPasswordData.showForm = false"} Cancel
.panel-row{"ng-if" => "newPasswordData.showForm"}
%form.panel-form.stretch
%input{:type => 'password', "ng-model" => "newPasswordData.newPassword", "placeholder" => "Enter new password"}
%input{:type => 'password', "ng-model" => "newPasswordData.newPasswordConfirmation", "placeholder" => "Confirm new password"}
.button-group.stretch.panel-row.form-submit
.button.info{"type" => "submit", "ng-click" => "submitPasswordChange()"}
.label Submit
%a{"ng-click" => "newPasswordData.changePassword = false; newPasswordData.showForm = false"} Cancel
%p.italic.mt-10{"ng-if" => "newPasswordData.status"} {{newPasswordData.status}}
%a.panel-row.condensed{"ng-click" => "showAdvanced = !showAdvanced"} Advanced
%div{"ng-if" => "showAdvanced"}
%a.panel-row{"ng-click" => "reencryptPressed()"} Resync All Items
%a.panel-row.condensed{"ng-if" => "securityUpdateAvailable()", "ng-click" => "clickedSecurityUpdate()"} Security Update Available
.notification.default{"ng-if" => "securityUpdateData.showForm"}
%p
%a{"href" => "https://standardnotes.org/help/security-update", "target" => "_blank"} Learn more.
%form.panel-form.stretch{"ng-if" => "!securityUpdateData.processing", "ng-submit" => "submitSecurityUpdateForm()"}
%p Enter your password to update:
%input.panel-row{:type => 'password', "ng-model" => "securityUpdateData.password", "placeholder" => "Enter password"}
.button-group.stretch.panel-row.form-submit
%button.button.info{"ng-type" => "submit"}
.label Update
.panel-row{"ng-if" => "securityUpdateData.processing"}
%p.info Processing...
%a.panel-row.condensed{"ng-click" => "openPasswordWizard('change-pw')"} Change Password
%a.panel-row.justify-left.condensed.success{"ng-if" => "securityUpdateAvailable", "ng-click" => "openPasswordWizard('upgrade-security')"}
.inline.circle.small.success.mr-8
.inline Security Update Available
.panel-section
%h3.title.panel-row Encryption
@@ -186,6 +158,9 @@
.button-group.stretch.panel-row.form-submit
%button.button.info{"type" => "submit"}
.label Decrypt & Import
%p
Importing from backup will overwrite existing notes with matching note from backup. Existing notes not found in the backup will remain as-is and won't be overwritten.
%p If you'd like to import only a selection of notes instead of the whole file, please use the Batch Manager extension instead.
.panel-row
.spinner.small.info{"ng-if" => "importData.loading"}
.footer

View File

@@ -2,7 +2,7 @@
.menu-panel.dropdown-menu
%a.no-decoration{"ng-if" => "extensions.length == 0", "href" => "https://standardnotes.org/extensions", "target" => "blank"}
%menu-row{"title" => "'Download Actions'"}
%menu-row{"label" => "'Download Actions'"}
%div{"ng-repeat" => "extension in extensions"}
.header{"ng-click" => "extension.hide = !extension.hide; $event.stopPropagation();"}
@@ -12,7 +12,7 @@
%div{"ng-if" => "extension.hide"} …
%menu-row{"ng-if" => "!extension.hide", "ng-repeat" => "action in extension.actionsWithContextForItem(item)",
"ng-click" => "executeAction(action, extension); $event.stopPropagation();", "title" => "action.label", "subtitle" => "action.desc",
"ng-click" => "executeAction(action, extension); $event.stopPropagation();", "label" => "action.label", "subtitle" => "action.desc",
"spinner-class" => "action.running ? 'info' : null", "sub-rows" => "action.subrows"}
.sublabel{"ng-if" => "action.access_type"}
Uses
@@ -20,7 +20,7 @@
access to this note.
.modal.medium{"ng-if" => "renderData.showRenderModal", "ng-click" => "$event.stopPropagation();"}
.modal.medium-text.medium{"ng-if" => "renderData.showRenderModal", "ng-click" => "$event.stopPropagation();"}
.content
.sn-component
.panel
@@ -29,4 +29,4 @@
%a.close-button.info{"ng-click" => "renderData.showRenderModal = false; $event.stopPropagation();"} Close
.content.selectable
%h2 {{renderData.title}}
%p.normal{"style" => "white-space: pre-wrap; font-family: monospace; font-size: 16px;"} {{renderData.text}}
%p.normal{"style" => "white-space: pre-wrap; font-size: 16px;"} {{renderData.text}}

View File

@@ -3,24 +3,24 @@
.section
.header
%h4.title Note Editor
%menu-row{"title" => "'Plain Editor'", "circle" => "selectedEditor == null && 'success'", "ng-click" => "selectComponent($event, null)"}
%menu-row{"label" => "'Plain Editor'", "circle" => "selectedEditor == null && 'success'", "ng-click" => "selectComponent($event, null)"}
%menu-row{"ng-repeat" => "editor in editors", "ng-click" => "selectComponent($event, editor)", "title" => "editor.name",
%menu-row{"ng-repeat" => "editor in editors", "ng-click" => "selectComponent($event, editor)", "label" => "editor.name",
"circle" => "selectedEditor === editor && 'success'",
"has-button" => "selectedEditor == editor || defaultEditor == editor", "button-text" => "defaultEditor == editor ? 'Undefault' : 'Set Default'",
"button-action" => "toggleDefaultForEditor(editor)", "button-class" => "defaultEditor == editor ? 'warning' : 'info'"}
.column{"ng-if" => "component.conflict_of || shouldDisplayRunningLocallyLabel(editor)"}
%strong.red.medium{"ng-if" => "editor.conflict_of"} Conflicted copy
%strong.red.medium-text{"ng-if" => "editor.conflict_of"} Conflicted copy
.sublabel{"ng-if" => "shouldDisplayRunningLocallyLabel(editor)"} Running Locally
%a.no-decoration{"ng-if" => "editors.length == 0", "href" => "https://standardnotes.org/extensions", "target" => "blank"}
%menu-row{"title" => "'Download More Editors'"}
%menu-row{"label" => "'Download More Editors'"}
.section{"ng-if" => "stack.length > 0"}
.header
%h4.title Editor Stack
%menu-row{"ng-repeat" => "component in stack", "ng-click" => "selectComponent($event, component)", "title" => "component.name",
%menu-row{"ng-repeat" => "component in stack", "ng-click" => "selectComponent($event, component)", "label" => "component.name",
"circle" => "stackComponentEnabled(component) ? 'success' : 'danger'"}
.column{"ng-if" => "component.conflict_of || shouldDisplayRunningLocallyLabel(component)"}
%strong.red.medium{"ng-if" => "component.conflict_of"} Conflicted copy
%strong.red.medium-text{"ng-if" => "component.conflict_of"} Conflicted copy
.sublabel{"ng-if" => "shouldDisplayRunningLocallyLabel(component)"} Running Locally

View File

@@ -0,0 +1,18 @@
.modal.small.auto-height
.content
.sn-component
.panel
.header
%h1.title {{title}}
%a.close-button{"ng-click" => "dismiss()"} Close
.content
.panel-section
%p.panel-row {{message}}
.panel-row
.panel-column.stretch
%form{"ng-submit" => "submit()"}
%input.form-control{:type => '{{type}}', "ng-model" => "formData.input", "placeholder" => "{{placeholder}}", "sn-autofocus" => "true", "should-focus" => "true"}
.footer
%a.right{"ng-click" => "submit()"}
Submit

View File

@@ -1,17 +1,17 @@
.row
.row{"ng-attr-title" => "{{desc}}"}
.column
.left
.column{"ng-if" => "circle"}
.circle.small{"ng-class" => "circle"}
.column{"ng-class" => "{'faded' : faded}"}
.label
{{title}}
{{label}}
.sublabel{"ng-if" => "subtitle"}
{{subtitle}}
%ng-transclude
.subrows{"ng-if" => "subRows && subRows.length > 0"}
%menu-row{"ng-repeat" => "row in subRows", "ng-click" => "row.onClick($event); $event.stopPropagation();",
"title" => "row.title", "subtitle" => "row.subtitle", "spinner-class" => "row.spinnerClass"}
"label" => "row.label", "subtitle" => "row.subtitle", "spinner-class" => "row.spinnerClass"}
.column{"ng-if" => "hasButton"}
.button.info{"ng-click" => "clickButton($event)", "ng-class" => "buttonClass"}

View File

@@ -0,0 +1,109 @@
#password-wizard.modal.small.auto-height
.content
.sn-component
.panel
.header
%h1.title {{title}}
%a.close-button{"ng-click" => "dismiss()"} Close
.content
%div{"ng-if" => "step == 0"}
%div{"ng-if" => "changePassword"}
%h2.title.panel-row Change your password
%p
Changing your password involves changing your encryption key, which requires your data to be re-encrypted and synced.
If you have many items, syncing your data can take several minutes.
%p.panel-row
%strong You must keep the application window open during this process.
%div{"ng-if" => "securityUpdate"}
%h2.title.panel-row Perform security update
%p
A new update is available for your account. Updates address improvements and enhancements to our security specification.
This process will guide you through the update, and perform the steps necessary with your supervision.
%p
For more information about security updates, please visit
%a{"href" => "https://standardnotes.org/help/security", "target" => "_blank"} standardnotes.org/help/security.
%p.panel-row
.info Press Continue to proceed.
.panel-row
.panel-row
.panel-section{"ng-if" => "step > 0"}
%h3.title.panel-row Step {{step}} — {{titleForStep(step)}}
%div{"ng-if" => "step == 1"}
%p.panel-row
As a result of this process, the entirety of your data will be re-encrypted and synced to your account. This is a generally safe process,
but unforeseen factors like poor network connectivity or a sudden shutdown of your computer may cause this process to fail.
It's best to be on the safe side before large operations such as this one.
.panel-row
.panel-row
.button-group
.button.info{"ng-click" => "downloadBackup(true)"}
.label Download Encrypted Backup
.button.info{"ng-click" => "downloadBackup(false)"}
.label Download Decrypted Backup
%div{"ng-if" => "step == 2"}
%p.panel-row
As a result of this process, your encryption keys will change.
Any device on which you use Standard Notes will need to end its session. After this process completes, you will be asked to sign back in.
%p.bold.panel-row.info-i Please sign out of all applications (excluding this one), including:
%ul
%li Desktop
%li Web (Chrome, Firefox, Safari)
%li Mobile (iOS and Android)
%p.panel-row
If you do not currently have access to a device you're signed in on, you may proceed,
but must make signing out and back in the first step upon gaining access to that device.
%p.panel-row Press Continue only when you have completed signing out of all your devices.
%div{"ng-if" => "step == 3"}
%div{"ng-if" => "changePassword"}
%div{"ng-if" => "securityUpdate"}
%p.panel-row Enter your current password. We'll run this through our encryption scheme to generate strong new encryption keys.
.panel-row
.panel-row
.panel-column.stretch
%form
%input.form-control{:type => 'password', "ng-model" => "formData.currentPassword", "placeholder" => "Current Password", "sn-autofocus" => "true", "should-focus" => "true"}
%input.form-control{"ng-if" => "changePassword", :type => 'password', "ng-model" => "formData.newPassword", "placeholder" => "New Password"}
%input.form-control{"ng-if" => "changePassword", :type => 'password', "ng-model" => "formData.newPasswordConfirmation", "placeholder" => "Confirm New Password"}
%div{"ng-if" => "step == 4"}
%p.panel-row
Your data is being re-encrypted with your new keys and synced to your account.
%p.panel-row.danger
Do not close this window until this process completes.
.panel-row
.panel-column
.spinner.small.inline.info.mr-5{"ng-if" => "formData.processing"}
.inline.bold{"ng-class" => "{'info' : !formData.statusError, 'error' : formData.statusError}"}
{{formData.status}}
.panel-column{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress || syncStatus.needsMoreSync", "delay" => "1000"}
%p.info
Syncing {{syncStatus.current}}/{{syncStatus.total}}
%div{"ng-if" => "step == 5"}
%div{"ng-if" => "changePassword"}
%p.panel-row Your password has been successfully changed.
%div{"ng-if" => "securityUpdate"}
%p.panel-row
The security update has been successfully applied to your account.
%p.panel-row
%strong Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum compatibility.
%p.panel-row You may now sign back in on all your devices and close this window.
.footer
.empty
%a.right{"ng-click" => "continue()", "ng-class" => "{'disabled' : lockContinue}"}
.spinner.small.inline.info.mr-5{"ng-if" => "showSpinner"}
{{continueTitle}}

View File

@@ -1,4 +1,4 @@
.section.editor#editor-column
.section.editor#editor-column{"aria-label" => "Note"}
.sn-component
.app-bar.no-edges{"ng-if" => "ctrl.note.locked", "ng-init" => "ctrl.lockText = 'Note Locked'", "ng-mouseover" => "ctrl.lockText = 'Unlock'", "ng-mouseleave" => "ctrl.lockText = 'Note Locked'"}
.left
@@ -13,7 +13,7 @@
"ng-change" => "ctrl.nameChanged()", "ng-focus" => "ctrl.onNameFocus()", "ng-blur" => "ctrl.onNameBlur()",
"select-on-click" => "true", "ng-disabled" => "ctrl.note.locked"}
#save-status{"ng-class" => "{'red bold': ctrl.saveError, 'warning bold': ctrl.syncTakingTooLong}", "ng-bind-html" => "ctrl.noteStatus"}
#save-status{"ng-class" => "{'error bold': ctrl.syncTakingTooLong}", "ng-bind-html" => "ctrl.noteStatus"}
.editor-tags
#note-tags-component-container{"ng-if" => "ctrl.tagsComponent"}
@@ -26,21 +26,21 @@
.app-bar.no-edges
.left
.item{"ng-click" => "ctrl.showMenu = !ctrl.showMenu; ctrl.showExtensions = false; ctrl.showEditorMenu = false;", "ng-class" => "{'selected' : ctrl.showMenu}", "click-outside" => "ctrl.showMenu = false;", "is-open" => "ctrl.showMenu"}
.label Menu
.label Options
.menu-panel.dropdown-menu{"ng-if" => "ctrl.showMenu"}
.section
.header
%h4.title Note Options
%menu-row{"title" => "ctrl.note.pinned ? 'Unpin' : 'Pin'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.togglePin()"}
%menu-row{"title" => "ctrl.note.archived ? 'Unarchive' : 'Archive'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleArchiveNote()"}
%menu-row{"title" => "ctrl.note.locked ? 'Unlock' : 'Lock'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleLockNote()"}
%menu-row{"title" => "'Delete'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.deleteNote()"}
%menu-row{"label" => "ctrl.note.pinned ? 'Unpin' : 'Pin'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.togglePin()", "desc" => "'Pin or unpin a note from the top of your list'"}
%menu-row{"label" => "ctrl.note.archived ? 'Unarchive' : 'Archive'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleArchiveNote()", "desc" => "'Archive or unarchive a note from your Archived system tag'"}
%menu-row{"label" => "ctrl.note.locked ? 'Unlock' : 'Lock'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleLockNote()", "desc" => "'Locking notes prevents unintentional editing'"}
%menu-row{"label" => "'Delete'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.deleteNote()", "desc" => "'Delete this note permanently from all your devices'"}
.section{"ng-if" => "!ctrl.selectedEditor"}
.header
%h4.title Global Display
%menu-row{"title" => "'Monospace Font'", "circle" => "ctrl.monospaceFont ? 'success' : 'default'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleKey('monospaceFont')"}
%menu-row{"title" => "'Spellcheck'", "circle" => "ctrl.spellcheck ? 'success' : 'default'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleKey('spellcheck')"}
%menu-row{"label" => "'Monospace Font'", "circle" => "ctrl.monospaceFont ? 'success' : 'default'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleKey('monospaceFont')", "desc" => "'Toggles the font style for the default editor'"}
%menu-row{"label" => "'Spellcheck'", "circle" => "ctrl.spellcheck ? 'success' : 'default'", "ng-click" => "ctrl.selectedMenuItem($event, true); ctrl.toggleKey('spellcheck')", "desc" => "'Toggles spellcheck for the default editor'"}
.item{"ng-click" => "ctrl.onEditorMenuClick()", "ng-class" => "{'selected' : ctrl.showEditorMenu}", "click-outside" => "ctrl.showEditorMenu = false;", "is-open" => "ctrl.showEditorMenu"}
.label Editor
@@ -63,7 +63,7 @@
%panel-resizer{"panel-id" => "'editor-content'", "on-resize-finish" => "ctrl.onPanelResizeFinish","control" => "ctrl.resizeControl", "min-width" => 300, "hoverable" => "true", "property" => "'right'"}
%section.section{"ng-if" => "ctrl.note.errorDecrypting"}
.section{"ng-if" => "ctrl.note.errorDecrypting"}
%p.medium-padding{"style" => "padding-top: 0 !important;"} There was an error decrypting this item. Ensure you are running the latest version of this app, then sign out and sign back in to try again.
#editor-pane-component-stack

View File

@@ -22,7 +22,10 @@
.right
.item{"ng-if" => "ctrl.newUpdateAvailable", "ng-click" => "ctrl.clickedNewUpdateAnnouncement()"}
.item{"ng-if" => "ctrl.securityUpdateAvailable", "ng-click" => "ctrl.openSecurityUpdate()"}
%span.success.label Security update available.
.item{"ng-if" => "ctrl.newUpdateAvailable == true", "ng-click" => "ctrl.clickedNewUpdateAnnouncement()"}
%span.info.label New update available.
.item.no-pointer{"ng-if" => "ctrl.lastSyncDate && !ctrl.isRefreshing"}

View File

@@ -1,13 +1,14 @@
.section.notes#notes-column
.section.notes#notes-column{"aria-label" => "Notes"}
.content
.section-title-bar#notes-title-bar
.padded
.section-title-bar-header
.title {{ctrl.panelTitle()}}
.add-button#notes-add-button{"ng-click" => "ctrl.createNewNote()"} +
.filter-section
.add-button#notes-add-button{"ng-click" => "ctrl.createNewNote()", "title" => "Create a new note in the selected tag"} +
.filter-section{"role" => "search"}
%input.filter-bar#search-bar.mousetrap{"select-on-click" => "true", "ng-model" => "ctrl.noteFilter.text", "placeholder" => "Search",
"ng-change" => "ctrl.filterTextChanged()", "lowercase" => "true", "ng-blur" => "ctrl.onFilterEnter()", "ng-keyup" => "$event.keyCode == 13 && ctrl.onFilterEnter();"}
"ng-change" => "ctrl.filterTextChanged()", "lowercase" => "true", "ng-blur" => "ctrl.onFilterEnter()", "ng-keyup" => "$event.keyCode == 13 && ctrl.onFilterEnter();",
"title" => "Searches notes in the currently selected tag"}
#search-clear-button{"ng-if" => "ctrl.noteFilter.text", "ng-click" => "ctrl.clearFilterText();"} ✕
.sn-component#notes-menu-bar
.app-bar.no-edges
@@ -25,35 +26,35 @@
.header
%h4.title Sort By
%menu-row{"title" => "'Date Added'", "circle" => "ctrl.sortBy == 'created_at' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByCreated()"}
%menu-row{"title" => "'Date Modified'", "circle" => "ctrl.sortBy == 'updated_at' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByUpdated()"}
%menu-row{"title" => "'Title'", "circle" => "ctrl.sortBy == 'title' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByTitle()"}
%menu-row{"label" => "'Date Added'", "circle" => "ctrl.sortBy == 'created_at' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByCreated()", "desc" => "'Sort notes by newest first'"}
%menu-row{"label" => "'Date Modified'", "circle" => "ctrl.sortBy == 'client_updated_at' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByUpdated()", "desc" => "'Sort notes with the most recently updated first'"}
%menu-row{"label" => "'Title'", "circle" => "ctrl.sortBy == 'title' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByTitle()", "desc" => "'Sort notes alphabetically by their title'"}
.section{"ng-if" => "!ctrl.tag.archiveTag"}
.header
%h4.title Display
%menu-row{"title" => "'Archived Notes'", "circle" => "ctrl.showArchived ? 'success' : 'danger'", "faded" => "!ctrl.showArchived", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('showArchived')"}
%menu-row{"title" => "'Pinned Notes'", "circle" => "ctrl.hidePinned ? 'danger' : 'success'", "faded" => "ctrl.hidePinned", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hidePinned')"}
%menu-row{"title" => "'Note Preview'", "circle" => "ctrl.hideNotePreview ? 'danger' : 'success'", "faded" => "ctrl.hideNotePreview", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hideNotePreview')"}
%menu-row{"title" => "'Date'", "circle" => "ctrl.hideDate ? 'danger' : 'success'","faded" => "ctrl.hideDate", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hideDate')"}
%menu-row{"title" => "'Tags'", "circle" => "ctrl.hideTags ? 'danger' : 'success'","faded" => "ctrl.hideTags", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hideTags')"}
%menu-row{"label" => "'Archived Notes'", "circle" => "ctrl.showArchived ? 'success' : 'danger'", "faded" => "!ctrl.showArchived", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('showArchived')", "desc" => "'Archived notes are usually hidden. You can explicitly show them with this option.'"}
%menu-row{"label" => "'Pinned Notes'", "circle" => "ctrl.hidePinned ? 'danger' : 'success'", "faded" => "ctrl.hidePinned", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hidePinned')", "desc" => "'Pinned notes always appear on top. You can hide them temporarily with this option so you can focus on other notes in the list.'"}
%menu-row{"label" => "'Note Preview'", "circle" => "ctrl.hideNotePreview ? 'danger' : 'success'", "faded" => "ctrl.hideNotePreview", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hideNotePreview')", "desc" => "'Hide the note preview for a more condensed list of notes'"}
%menu-row{"label" => "'Date'", "circle" => "ctrl.hideDate ? 'danger' : 'success'","faded" => "ctrl.hideDate", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hideDate')", "desc" => "'Hide the date displayed in each row'"}
%menu-row{"label" => "'Tags'", "circle" => "ctrl.hideTags ? 'danger' : 'success'","faded" => "ctrl.hideTags", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleKey('hideTags')", "desc" => "'Hide the list of tags associated with each note'"}
.scrollable
.infinite-scroll#notes-scrollable{"infinite-scroll" => "ctrl.paginate()", "can-load" => "true", "threshold" => "200"}
.note{"ng-repeat" => "note in (ctrl.sortedNotes = (ctrl.tag.notes | filter: ctrl.filterNotes | sortBy: ctrl.sortBy | limitTo:ctrl.notesToDisplay)) track by note.uuid",
"ng-click" => "ctrl.selectNote(note, true)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"}
%strong.red.medium{"ng-if" => "note.conflict_of"} Conflicted copy
%strong.red.medium{"ng-if" => "note.errorDecrypting"} Error decrypting
%strong.red.medium-text{"ng-if" => "note.conflict_of"} Conflicted copy
%strong.red.medium-text{"ng-if" => "note.errorDecrypting"} Error decrypting
.pinned.tinted{"ng-if" => "note.pinned", "ng-class" => "{'tinted-selected' : ctrl.selectedNote == note}"}
%i.icon.ion-bookmark
%strong.medium Pinned
%strong.medium-text Pinned
.archived.tinted{"ng-if" => "note.archived && !ctrl.tag.archiveTag", "ng-class" => "{'tinted-selected' : ctrl.selectedNote == note}"}
%i.icon.ion-ios-box
%strong.medium Archived
%strong.medium-text Archived
.tags-string{"ng-if" => "ctrl.shouldShowTags(note)"}
.faded {{note.savedTagsString || note.tagsString()}}
@@ -63,7 +64,7 @@
.note-preview{"ng-if" => "!ctrl.hideNotePreview"}
{{note.text}}
.date.faded{"ng-if" => "!ctrl.hideDate"}
%span{"ng-if" => "ctrl.sortBy == 'updated_at'"} Modified {{note.updatedAtString() || 'Now'}}
%span{"ng-if" => "ctrl.sortBy != 'updated_at'"} {{note.createdAtString() || 'Now'}}
%span{"ng-if" => "ctrl.sortBy == 'client_updated_at'"} Modified {{note.updatedAtString() || 'Now'}}
%span{"ng-if" => "ctrl.sortBy != 'client_updated_at'"} {{note.createdAtString() || 'Now'}}
%panel-resizer{"panel-id" => "'notes-column'", "on-resize-finish" => "ctrl.onPanelResize", "control" => "ctrl.panelController", "hoverable" => "true", "collapsable" => "true"}

View File

@@ -1,4 +1,4 @@
.section.tags#tags-column
.section.tags#tags-column{"aria-label" => "Tags"}
.component-view-container{"ng-if" => "ctrl.component.active"}
%component-view.component-view{"component" => "ctrl.component"}
@@ -7,7 +7,7 @@
#tags-title-bar.section-title-bar
.section-title-bar-header
.title Tags
.add-button#tag-add-button{"ng-click" => "ctrl.clickedAddNewTag()"} +
.add-button#tag-add-button{"ng-click" => "ctrl.clickedAddNewTag()", "title" => "Create a new tag"} +
.scrollable
.infinite-scroll
@@ -22,8 +22,8 @@
"ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-blur" => "ctrl.saveTag($event, tag)", "spellcheck" => "false"}
.count {{ctrl.noteCount(tag)}}
.red.small.bold{"ng-if" => "tag.conflict_of"} Conflicted copy
.red.small.bold{"ng-if" => "tag.errorDecrypting"} Error decrypting
.red.small-text.bold{"ng-if" => "tag.conflict_of"} Conflicted copy
.red.small-text.bold{"ng-if" => "tag.errorDecrypting"} Error decrypting
.menu{"ng-if" => "ctrl.selectedTag == tag"}
%a.item{"ng-click" => "ctrl.selectedRenameTag($event, tag)", "ng-if" => "!ctrl.editingTag"} Rename

9720
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,10 @@
"angular": "^1.6.1",
"angular-mocks": "^1.6.1",
"babel-cli": "^6.18.0",
"babel-plugin-angularjs-annotate": "^0.9.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.1.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-es2016": "^6.16.0",
"bower": "^1.8.0",
"grunt": "^1.0.1",
@@ -33,8 +36,8 @@
"karma-cli": "^1.0.1",
"karma-jasmine": "^1.1.0",
"karma-phantomjs-launcher": "^1.0.2",
"sn-stylekit": "1.0.12",
"standard-file-js": "0.0.14"
"sn-stylekit": "1.0.15",
"standard-file-js": "0.3.0"
},
"license": "GPL-3.0"
}