diff --git a/app/assets/javascripts/app/controllers/editor.js b/app/assets/javascripts/app/controllers/editor.js index c1a4416a3..ed97fe6a8 100644 --- a/app/assets/javascripts/app/controllers/editor.js +++ b/app/assets/javascripts/app/controllers/editor.js @@ -22,7 +22,8 @@ angular.module('app') } } }) - .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, actionsManager, syncManager, modelManager, themeManager, componentManager, storageManager, sessionHistory) { + .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, actionsManager, + syncManager, modelManager, themeManager, componentManager, storageManager, sessionHistory, privilegesManager) { this.spellcheck = true; this.componentManager = componentManager; @@ -386,16 +387,28 @@ angular.module('app') } } - this.deleteNote = function() { - if(this.note.locked) { - alert("This note is locked. If you'd like to delete it, unlock it, and try again."); - return; + this.deleteNote = async function() { + let run = () => { + $timeout(() => { + if(this.note.locked) { + alert("This note is locked. If you'd like to delete it, unlock it, and try again."); + return; + } + + let title = this.note.safeTitle().length ? `'${this.note.title}'` : "this note"; + if(confirm(`Are you sure you want to delete ${title}?`)) { + this.remove()(this.note); + this.showMenu = false; + } + }); } - let title = this.note.safeTitle().length ? `'${this.note.title}'` : "this note"; - if(confirm(`Are you sure you want to delete ${title}?`)) { - this.remove()(this.note); - this.showMenu = false; + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionDeleteNote)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionDeleteNote, () => { + run(); + }); + } else { + run(); } } diff --git a/app/assets/javascripts/app/controllers/footer.js b/app/assets/javascripts/app/controllers/footer.js index d408d1be4..5aeb5b176 100644 --- a/app/assets/javascripts/app/controllers/footer.js +++ b/app/assets/javascripts/app/controllers/footer.js @@ -23,7 +23,8 @@ angular.module('app') } }) .controller('FooterCtrl', function ($rootScope, authManager, modelManager, $timeout, dbManager, - syncManager, storageManager, passcodeManager, componentManager, singletonManager, nativeExtManager) { + syncManager, storageManager, passcodeManager, componentManager, singletonManager, nativeExtManager, + privilegesManager) { authManager.checkForSecurityUpdate().then((available) => { this.securityUpdateAvailable = available; @@ -172,7 +173,31 @@ angular.module('app') } } - this.selectRoom = function(room) { - room.showRoom = !room.showRoom; + this.selectRoom = async function(room) { + let run = () => { + $timeout(() => { + room.showRoom = !room.showRoom; + }) + } + + if(!room.showRoom) { + // About to show, check if has privileges + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManageExtensions)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManageExtensions, () => { + run(); + }); + } else { + run(); + } + } else { + run(); + } + } + + this.clickOutsideAccountMenu = function() { + if(privilegesManager.authenticationInProgress()) { + return; + } + this.showAccountMenu = false; } }); diff --git a/app/assets/javascripts/app/controllers/home.js b/app/assets/javascripts/app/controllers/home.js index 6d8805094..72e095a93 100644 --- a/app/assets/javascripts/app/controllers/home.js +++ b/app/assets/javascripts/app/controllers/home.js @@ -1,6 +1,7 @@ angular.module('app') .controller('HomeCtrl', function ($scope, $location, $rootScope, $timeout, modelManager, - dbManager, syncManager, authManager, themeManager, passcodeManager, storageManager, migrationManager) { + dbManager, syncManager, authManager, themeManager, passcodeManager, storageManager, migrationManager, + privilegesManager) { storageManager.initialize(passcodeManager.hasPasscode(), authManager.isEphemeralSession()); @@ -83,14 +84,14 @@ angular.module('app') syncManager.loadLocalItems().then(() => { $timeout(() => { $scope.allTag.didLoad = true; - $rootScope.$broadcast("initial-data-loaded"); + $rootScope.$broadcast("initial-data-loaded"); // This needs to be processed first before sync is called so that singletonManager observers function properly. + syncManager.sync(); + // refresh every 30s + setInterval(function () { + syncManager.sync(); + }, 30000); }) - syncManager.sync(); - // refresh every 30s - setInterval(function () { - syncManager.sync(); - }, 30000); }); authManager.addEventHandler((event) => { diff --git a/app/assets/javascripts/app/controllers/lockScreen.js b/app/assets/javascripts/app/controllers/lockScreen.js index e38eff9f6..8f3a9efd0 100644 --- a/app/assets/javascripts/app/controllers/lockScreen.js +++ b/app/assets/javascripts/app/controllers/lockScreen.js @@ -41,7 +41,6 @@ class LockScreen { }) } } - } angular.module('app').directive('lockScreen', () => new LockScreen); diff --git a/app/assets/javascripts/app/controllers/notes.js b/app/assets/javascripts/app/controllers/notes.js index 1c33e0d1b..6c0f54644 100644 --- a/app/assets/javascripts/app/controllers/notes.js +++ b/app/assets/javascripts/app/controllers/notes.js @@ -31,7 +31,8 @@ angular.module('app') } } }) - .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager, storageManager, desktopManager) { + .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager, + storageManager, desktopManager, privilegesManager) { this.panelController = {}; @@ -198,19 +199,31 @@ angular.module('app') } } - this.selectNote = function(note, viaClick = false) { + this.selectNote = async function(note, viaClick = false) { if(!note) { this.createNewNote(); return; } - this.selectedNote = note; - note.conflict_of = null; // clear conflict - this.selectionMade()(note); - this.selectedIndex = Math.max(this.visibleNotes().indexOf(note), 0); + let run = () => { + $timeout(() => { + this.selectedNote = note; + note.conflict_of = null; // clear conflict + this.selectionMade()(note); + this.selectedIndex = Math.max(this.visibleNotes().indexOf(note), 0); - if(viaClick && this.isFiltering()) { - desktopManager.searchText(this.noteFilter.text); + if(viaClick && this.isFiltering()) { + desktopManager.searchText(this.noteFilter.text); + } + }) + } + + if(note.locked && await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionViewLockedNotes)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionViewLockedNotes, () => { + run(); + }); + } else { + run(); } } diff --git a/app/assets/javascripts/app/directives/functional/infiniteScroll.js b/app/assets/javascripts/app/directives/functional/infiniteScroll.js index 3e1e633ef..21bacd325 100644 --- a/app/assets/javascripts/app/directives/functional/infiniteScroll.js +++ b/app/assets/javascripts/app/directives/functional/infiniteScroll.js @@ -2,9 +2,6 @@ angular.module('app').directive('infiniteScroll', [ '$rootScope', '$window', '$timeout', function($rootScope, $window, $timeout) { return { link: function(scope, elem, attrs) { - // elem.css('overflow-x', 'hidden'); - // elem.css('height', 'inherit'); - var offset = parseInt(attrs.threshold) || 0; var e = elem[0] diff --git a/app/assets/javascripts/app/directives/functional/snEnter.js b/app/assets/javascripts/app/directives/functional/snEnter.js new file mode 100644 index 000000000..fe12e0983 --- /dev/null +++ b/app/assets/javascripts/app/directives/functional/snEnter.js @@ -0,0 +1,15 @@ +angular +.module('app') +.directive('snEnter', function() { + return function(scope, element, attrs) { + element.bind("keydown keypress", function(event) { + if(event.which === 13) { + scope.$apply(function(){ + scope.$eval(attrs.snEnter, {'event': event}); + }); + + event.preventDefault(); + } + }); + }; +}); diff --git a/app/assets/javascripts/app/directives/views/accountMenu.js b/app/assets/javascripts/app/directives/views/accountMenu.js index d24684515..ae33c16c2 100644 --- a/app/assets/javascripts/app/directives/views/accountMenu.js +++ b/app/assets/javascripts/app/directives/views/accountMenu.js @@ -10,7 +10,7 @@ class AccountMenu { } controller($scope, $rootScope, authManager, modelManager, syncManager, storageManager, dbManager, passcodeManager, - $timeout, $compile, archiveManager) { + $timeout, $compile, archiveManager, privilegesManager) { 'ngInject'; $scope.formData = {mergeLocal: true, ephemeral: false}; @@ -38,7 +38,6 @@ class AccountMenu { } $scope.canAddPasscode = !authManager.isEphemeralSession(); - $scope.syncStatus = syncManager.syncStatus; $scope.submitMfaForm = function() { @@ -167,10 +166,27 @@ class AccountMenu { $scope.openPasswordWizard = function(type) { // Close the account menu $scope.close(); - authManager.presentPasswordWizard(type); } + $scope.openPrivilegesModal = async function() { + $scope.close(); + + let run = () => { + $timeout(() => { + privilegesManager.presentPrivilegesManagementModal(); + }) + } + + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManagePrivileges)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManagePrivileges, () => { + run(); + }); + } else { + run(); + } + } + // 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 @@ -229,36 +245,49 @@ class AccountMenu { }) } - $scope.importFileSelected = function(files) { - $scope.importData = {}; + $scope.importFileSelected = async function(files) { - var file = files[0]; - var reader = new FileReader(); - reader.onload = function(e) { - try { - var data = JSON.parse(e.target.result); - $timeout(function(){ - if(data.auth_params) { - // request password - $scope.importData.requestPassword = true; - $scope.importData.data = data; + let run = () => { + $timeout(() => { + $scope.importData = {}; - $timeout(() => { - var element = document.getElementById("import-password-request"); - if(element) { - element.scrollIntoView(false); + var file = files[0]; + var reader = new FileReader(); + reader.onload = function(e) { + try { + var data = JSON.parse(e.target.result); + $timeout(function(){ + if(data.auth_params) { + // request password + $scope.importData.requestPassword = true; + $scope.importData.data = data; + + $timeout(() => { + var element = document.getElementById("import-password-request"); + if(element) { + element.scrollIntoView(false); + } + }) + } else { + $scope.performImport(data, null); } }) - } else { - $scope.performImport(data, null); + } catch (e) { + alert("Unable to open file. Ensure it is a proper JSON file and try again."); } - }) - } catch (e) { - alert("Unable to open file. Ensure it is a proper JSON file and try again."); - } + } + + reader.readAsText(file); + }) } - reader.readAsText(file); + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManageBackups)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManageBackups, () => { + run(); + }); + } else { + run(); + } } $scope.importJSONData = function(data, password, callback) { @@ -316,8 +345,18 @@ class AccountMenu { Export */ - $scope.downloadDataArchive = function() { - archiveManager.downloadBackup($scope.archiveFormData.encrypted); + $scope.downloadDataArchive = async function() { + let run = () => { + archiveManager.downloadBackup($scope.archiveFormData.encrypted); + } + + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManageBackups)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManageBackups, () => { + run(); + }); + } else { + run(); + } } /* @@ -362,6 +401,35 @@ class AccountMenu { Passcode Lock */ + $scope.passcodeAutoLockOptions = passcodeManager.getAutoLockIntervalOptions(); + + $scope.reloadAutoLockInterval = function() { + passcodeManager.getAutoLockInterval().then((interval) => { + $timeout(() => { + $scope.selectedAutoLockInterval = interval; + }) + }) + } + + $scope.reloadAutoLockInterval(); + + $scope.selectAutoLockInterval = async function(interval) { + let run = async () => { + await passcodeManager.setAutoLockInterval(interval); + $timeout(() => { + $scope.reloadAutoLockInterval(); + }); + } + + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManagePasscode)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManagePasscode, () => { + run(); + }); + } else { + run(); + } + } + $scope.hasPasscode = function() { return passcodeManager.hasPasscode(); } @@ -377,10 +445,10 @@ class AccountMenu { return; } - let fn = $scope.formData.changingPasscode ? passcodeManager.changePasscode : passcodeManager.setPasscode; + let fn = $scope.formData.changingPasscode ? passcodeManager.changePasscode.bind(passcodeManager) : passcodeManager.setPasscode.bind(passcodeManager); fn(passcode, () => { - $timeout(function(){ + $timeout(() => { $scope.formData.showPasscodeForm = false; var offline = authManager.offline(); @@ -393,27 +461,51 @@ class AccountMenu { }) } - $scope.changePasscodePressed = function() { - $scope.formData.changingPasscode = true; - $scope.addPasscodeClicked(); - $scope.formData.changingPasscode = false; + $scope.changePasscodePressed = async function() { + let run = () => { + $timeout(() => { + $scope.formData.changingPasscode = true; + $scope.addPasscodeClicked(); + $scope.formData.changingPasscode = false; + }) + } + + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManagePasscode)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManagePasscode, () => { + run(); + }); + } else { + run(); + } } - $scope.removePasscodePressed = function() { - var signedIn = !authManager.offline(); - var message = "Are you sure you want to remove your local passcode?"; - if(!signedIn) { - message += " This will remove encryption from your local data."; - } - if(confirm(message)) { - passcodeManager.clearPasscode(); + $scope.removePasscodePressed = async function() { + let run = () => { + $timeout(() => { + var signedIn = !authManager.offline(); + var message = "Are you sure you want to remove your local passcode?"; + if(!signedIn) { + message += " This will remove encryption from your local data."; + } + if(confirm(message)) { + passcodeManager.clearPasscode(); - if(authManager.offline()) { - syncManager.markAllItemsDirtyAndSaveOffline(); - // Don't create backup here, as if the user is temporarily removing the passcode to change it, - // we don't want to write unencrypted data to disk. - // $rootScope.$broadcast("major-data-change"); - } + if(authManager.offline()) { + syncManager.markAllItemsDirtyAndSaveOffline(); + // Don't create backup here, as if the user is temporarily removing the passcode to change it, + // we don't want to write unencrypted data to disk. + // $rootScope.$broadcast("major-data-change"); + } + } + }) + } + + if(await privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManagePasscode)) { + privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManagePasscode, () => { + run(); + }); + } else { + run(); } } diff --git a/app/assets/javascripts/app/directives/views/menuRow.js b/app/assets/javascripts/app/directives/views/menuRow.js index 999e64f9f..49fbb5e0a 100644 --- a/app/assets/javascripts/app/directives/views/menuRow.js +++ b/app/assets/javascripts/app/directives/views/menuRow.js @@ -7,6 +7,7 @@ class MenuRow { this.scope = { action: "&", circle: "=", + circleAlign: "=", label: "=", subtitle: "=", hasButton: "=", diff --git a/app/assets/javascripts/app/directives/views/privilegesAuthModal.js b/app/assets/javascripts/app/directives/views/privilegesAuthModal.js new file mode 100644 index 000000000..c677da92b --- /dev/null +++ b/app/assets/javascripts/app/directives/views/privilegesAuthModal.js @@ -0,0 +1,97 @@ +/* + The purpose of the conflict resoltion modal is to present two versions of a conflicted item, + and allow the user to choose which to keep (or to keep both.) +*/ + +class PrivilegesAuthModal { + + constructor() { + this.restrict = "E"; + this.templateUrl = "directives/privileges-auth-modal.html"; + this.scope = { + action: "=", + onSuccess: "=", + onCancel: "=", + }; + } + + link($scope, el, attrs) { + $scope.dismiss = function() { + el.remove(); + } + } + + controller($scope, privilegesManager, passcodeManager, authManager, $timeout) { + 'ngInject'; + + $scope.authenticationParameters = {}; + $scope.sessionLengthOptions = privilegesManager.getSessionLengthOptions(); + + privilegesManager.getSelectedSessionLength().then((length) => { + $timeout(() => { + $scope.selectedSessionLength = length; + }) + }) + + $scope.selectSessionLength = function(length) { + $scope.selectedSessionLength = length; + } + + privilegesManager.netCredentialsForAction($scope.action).then((credentials) => { + $timeout(() => { + $scope.requiredCredentials = credentials.sort(); + }); + }); + + $scope.promptForCredential = function(credential) { + return privilegesManager.displayInfoForCredential(credential).prompt; + } + + $scope.cancel = function() { + $scope.dismiss(); + $scope.onCancel && $scope.onCancel(); + } + + $scope.isCredentialInFailureState = function(credential) { + if(!$scope.failedCredentials) { + return false; + } + return $scope.failedCredentials.find((candidate) => { + return candidate == credential; + }) != null; + } + + $scope.validate = function() { + var failed = []; + for(var cred of $scope.requiredCredentials) { + var value = $scope.authenticationParameters[cred]; + if(!value || value.length == 0) { + failed.push(cred); + } + } + + $scope.failedCredentials = failed; + return failed.length == 0; + } + + $scope.submit = function() { + if(!$scope.validate()) { + return; + } + privilegesManager.authenticateAction($scope.action, $scope.authenticationParameters).then((result) => { + $timeout(() => { + if(result.success) { + privilegesManager.setSessionLength($scope.selectedSessionLength); + $scope.onSuccess(); + $scope.dismiss(); + } else { + $scope.failedCredentials = result.failedCredentials; + } + }) + }) + } + + } +} + +angular.module('app').directive('privilegesAuthModal', () => new PrivilegesAuthModal); diff --git a/app/assets/javascripts/app/directives/views/privilegesManagementModal.js b/app/assets/javascripts/app/directives/views/privilegesManagementModal.js new file mode 100644 index 000000000..bec8691d4 --- /dev/null +++ b/app/assets/javascripts/app/directives/views/privilegesManagementModal.js @@ -0,0 +1,88 @@ +class PrivilegesManagementModal { + + constructor() { + this.restrict = "E"; + this.templateUrl = "directives/privileges-management-modal.html"; + this.scope = { + + }; + } + + link($scope, el, attrs) { + $scope.dismiss = function() { + el.remove(); + } + } + + controller($scope, privilegesManager, passcodeManager, authManager, $timeout) { + 'ngInject'; + + $scope.dummy = {}; + + $scope.hasPasscode = passcodeManager.hasPasscode(); + $scope.hasAccount = !authManager.offline(); + + $scope.displayInfoForCredential = function(credential) { + let info = privilegesManager.displayInfoForCredential(credential); + if(credential == PrivilegesManager.CredentialLocalPasscode) { + info["availability"] = $scope.hasPasscode; + } else if(credential == PrivilegesManager.CredentialAccountPassword) { + info["availability"] = $scope.hasAccount; + } else { + info["availability"] = true; + } + + return info; + } + + $scope.displayInfoForAction = function(action) { + return privilegesManager.displayInfoForAction(action).label; + } + + $scope.isCredentialRequiredForAction = function(action, credential) { + if(!$scope.privileges) { + return false; + } + return $scope.privileges.isCredentialRequiredForAction(action, credential); + } + + $scope.clearSession = function() { + privilegesManager.clearSession().then(() => { + $scope.reloadPrivileges(); + }) + } + + $scope.reloadPrivileges = async function() { + $scope.availableActions = privilegesManager.getAvailableActions(); + $scope.availableCredentials = privilegesManager.getAvailableCredentials(); + let sessionEndDate = await privilegesManager.getSessionExpirey(); + $scope.sessionExpirey = sessionEndDate.toLocaleString(); + $scope.sessionExpired = new Date() >= sessionEndDate; + + $scope.credentialDisplayInfo = {}; + for(let cred of $scope.availableCredentials) { + $scope.credentialDisplayInfo[cred] = $scope.displayInfoForCredential(cred); + } + + privilegesManager.getPrivileges().then((privs) => { + $timeout(() => { + $scope.privileges = privs; + }) + }) + } + + $scope.checkboxValueChanged = function(action, credential) { + $scope.privileges.toggleCredentialForAction(action, credential); + privilegesManager.savePrivileges(); + } + + $scope.reloadPrivileges(); + + $scope.cancel = function() { + $scope.dismiss(); + $scope.onCancel && $scope.onCancel(); + } + } +} + +angular.module('app').directive('privilegesManagementModal', () => new PrivilegesManagementModal); diff --git a/app/assets/javascripts/app/models/privileges.js b/app/assets/javascripts/app/models/privileges.js new file mode 100644 index 000000000..3fb5f96fa --- /dev/null +++ b/app/assets/javascripts/app/models/privileges.js @@ -0,0 +1,34 @@ +class SNPrivileges extends SFItem { + + setCredentialsForAction(action, credentials) { + this.content.desktopPrivileges[action] = credentials; + } + + getCredentialsForAction(action) { + return this.content.desktopPrivileges[action] || []; + } + + toggleCredentialForAction(action, credential) { + if(this.isCredentialRequiredForAction(action, credential)) { + this.removeCredentialForAction(action, credential); + } else { + this.addCredentialForAction(action, credential); + } + } + + removeCredentialForAction(action, credential) { + _.pull(this.content.desktopPrivileges[action], credential); + } + + addCredentialForAction(action, credential) { + var credentials = this.getCredentialsForAction(action); + credentials.push(credential); + this.setCredentialsForAction(action, credentials); + } + + isCredentialRequiredForAction(action, credential) { + var credentialsRequired = this.getCredentialsForAction(action); + return credentialsRequired.includes(credential); + } + +} diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js index 6787d3709..3e4381e1f 100644 --- a/app/assets/javascripts/app/services/authManager.js +++ b/app/assets/javascripts/app/services/authManager.js @@ -92,6 +92,13 @@ class AuthManager extends SFAuthManager { } } + async verifyAccountPassword(password) { + let authParams = await this.getAuthParams(); + let keys = await SFJS.crypto.computeEncryptionKeysForUser(password, authParams); + let success = keys.mk === (await this.keys()).mk; + return success; + } + async checkForSecurityUpdate() { if(this.offline()) { return false; @@ -128,6 +135,7 @@ class AuthManager extends SFAuthManager { let contentTypePredicate = new SFPredicate("content_type", "=", prefsContentType); this.singletonManager.registerSingleton([contentTypePredicate], (resolvedSingleton) => { + // console.log("Loaded existing user prefs", resolvedSingleton.uuid); this.userPreferences = resolvedSingleton; this.userPreferencesDidChange(); }, (valueCallback) => { diff --git a/app/assets/javascripts/app/services/httpManager.js b/app/assets/javascripts/app/services/httpManager.js index 637db6041..a3415b4b5 100644 --- a/app/assets/javascripts/app/services/httpManager.js +++ b/app/assets/javascripts/app/services/httpManager.js @@ -5,7 +5,7 @@ class HttpManager extends SFHttpManager { super($timeout); this.setJWTRequestHandler(async () => { - return storageManager.getItem("jwt");; + return storageManager.getItem("jwt"); }) } } diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index c3f30c207..9af2df562 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -7,7 +7,8 @@ SFModelManager.ContentTypeClassMapping = { "SN|Theme" : SNTheme, "SN|Component" : SNComponent, "SF|Extension" : SNServerExtension, - "SF|MFA" : SNMfa + "SF|MFA" : SNMfa, + "SN|Privileges" : SNPrivileges }; SFItem.AppDomain = "org.standardnotes.sn"; diff --git a/app/assets/javascripts/app/services/passcodeManager.js b/app/assets/javascripts/app/services/passcodeManager.js index 47c9e763b..d203f5aba 100644 --- a/app/assets/javascripts/app/services/passcodeManager.js +++ b/app/assets/javascripts/app/services/passcodeManager.js @@ -1,100 +1,186 @@ -angular.module('app') - .provider('passcodeManager', function () { +class PasscodeManager { - this.$get = function($rootScope, $timeout, modelManager, dbManager, authManager, storageManager) { - return new PasscodeManager($rootScope, $timeout, modelManager, dbManager, authManager, storageManager); - } + constructor(authManager, storageManager) { + document.addEventListener('visibilitychange', (e) => { + this.documentVisibilityChanged(document.visibilityState); + }); - function PasscodeManager($rootScope, $timeout, modelManager, dbManager, authManager, storageManager) { + this.authManager = authManager; + this.storageManager = storageManager; - this._hasPasscode = storageManager.getItemSync("offlineParams", StorageManager.Fixed) != null; + this._hasPasscode = this.storageManager.getItemSync("offlineParams", StorageManager.Fixed) != null; this._locked = this._hasPasscode; - this.isLocked = function() { - return this._locked; - } + const MillisecondsPerSecond = 1000; + PasscodeManager.AutoLockIntervalNone = 0; + PasscodeManager.AutoLockIntervalOneMinute = 60 * MillisecondsPerSecond; + PasscodeManager.AutoLockIntervalFiveMinutes = 300 * MillisecondsPerSecond; + PasscodeManager.AutoLockIntervalOneHour = 3600 * MillisecondsPerSecond; - this.hasPasscode = function() { - return this._hasPasscode; - } + PasscodeManager.AutoLockIntervalKey = "AutoLockIntervalKey"; + } - this.keys = function() { - return this._keys; - } - - this.passcodeAuthParams = function() { - var authParams = JSON.parse(storageManager.getItemSync("offlineParams", StorageManager.Fixed)); - if(authParams && !authParams.version) { - 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. - authParams.version = "002"; - } else { - authParams.version = "001"; - } + getAutoLockIntervalOptions() { + return [ + { + value: PasscodeManager.AutoLockIntervalNone, + label: "None" + }, + { + value: PasscodeManager.AutoLockIntervalOneMinute, + label: "1 Min" + }, + { + value: PasscodeManager.AutoLockIntervalFiveMinutes, + label: "5 Min" + }, + { + value: PasscodeManager.AutoLockIntervalOneHour, + label: "1 Hr" } - return authParams; - } + ] + } - this.unlock = function(passcode, callback) { - var params = this.passcodeAuthParams(); - SFJS.crypto.computeEncryptionKeysForUser(passcode, params).then((keys) => { - if(keys.pw !== params.hash) { - callback(false); - return; - } - - this._keys = keys; - this._authParams = params; - this.decryptLocalStorage(keys, params).then(() => { - this._locked = false; - callback(true); - }) - }); - } - - this.setPasscode = (passcode, callback) => { - var uuid = SFJS.crypto.generateUUIDSync(); - - 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, authParams); - - // After it's cleared, it's safe to write to it - storageManager.setItem("offlineParams", JSON.stringify(authParams), StorageManager.Fixed); - callback(true); - }); - } - - this.changePasscode = (newPasscode, callback) => { - this.setPasscode(newPasscode, callback); - } - - this.clearPasscode = function() { - storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.Fixed); // Transfer from Ephemeral - storageManager.removeItem("offlineParams", StorageManager.Fixed); - this._keys = null; - this._hasPasscode = false; - } - - 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 = async function(keys, authParams) { - storageManager.setKeys(keys, authParams); - return storageManager.decryptStorage(); + documentVisibilityChanged(visbility) { + let visible = document.visibilityState == "visible"; + if(!visible) { + this.beginAutoLockTimer(); + } else { + this.cancelAutoLockTimer(); } } -}); + + async beginAutoLockTimer() { + var interval = await this.getAutoLockInterval(); + if(interval == PasscodeManager.AutoLockIntervalNone) { + return; + } + + this.lockTimeout = setTimeout(() => { + this.lockApplication(); + }, interval); + } + + cancelAutoLockTimer() { + clearTimeout(this.lockTimeout); + } + + lockApplication() { + window.location.reload(); + this.cancelAutoLockTimer(); + } + + isLocked() { + return this._locked; + } + + hasPasscode() { + return this._hasPasscode; + } + + keys() { + return this._keys; + } + + async setAutoLockInterval(interval) { + return this.storageManager.setItem(PasscodeManager.AutoLockIntervalKey, JSON.stringify(interval), StorageManager.FixedEncrypted); + } + + async getAutoLockInterval() { + let interval = await this.storageManager.getItem(PasscodeManager.AutoLockIntervalKey, StorageManager.FixedEncrypted); + if(interval) { + return JSON.parse(interval); + } else { + return PasscodeManager.AutoLockIntervalNone; + } + } + + passcodeAuthParams() { + var authParams = JSON.parse(this.storageManager.getItemSync("offlineParams", StorageManager.Fixed)); + if(authParams && !authParams.version) { + 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 their version stored in authParams. + authParams.version = "002"; + } else { + authParams.version = "001"; + } + } + return authParams; + } + + async verifyPasscode(passcode) { + return new Promise(async (resolve, reject) => { + var params = this.passcodeAuthParams(); + let keys = await SFJS.crypto.computeEncryptionKeysForUser(passcode, params); + if(keys.pw !== params.hash) { + resolve(false); + } else { + resolve(true); + } + }) + } + + unlock(passcode, callback) { + var params = this.passcodeAuthParams(); + SFJS.crypto.computeEncryptionKeysForUser(passcode, params).then((keys) => { + if(keys.pw !== params.hash) { + callback(false); + return; + } + + this._keys = keys; + this._authParams = params; + this.decryptLocalStorage(keys, params).then(() => { + this._locked = false; + callback(true); + }) + }); + } + + setPasscode(passcode, callback) { + var uuid = SFJS.crypto.generateUUIDSync(); + + 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, authParams); + + // After it's cleared, it's safe to write to it + this.storageManager.setItem("offlineParams", JSON.stringify(authParams), StorageManager.Fixed); + callback(true); + }); + } + + changePasscode(newPasscode, callback) { + this.setPasscode(newPasscode, callback); + } + + clearPasscode() { + this.storageManager.setItemsMode(this.authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.Fixed); // Transfer from Ephemeral + this.storageManager.removeItem("offlineParams", StorageManager.Fixed); + this._keys = null; + this._hasPasscode = false; + } + + encryptLocalStorage(keys, authParams) { + this.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 + this.storageManager.setItemsMode(this.authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.FixedEncrypted, true); + } + + async decryptLocalStorage(keys, authParams) { + this.storageManager.setKeys(keys, authParams); + return this.storageManager.decryptStorage(); + } +} + +angular.module('app').service('passcodeManager', PasscodeManager); diff --git a/app/assets/javascripts/app/services/privilegesManager.js b/app/assets/javascripts/app/services/privilegesManager.js new file mode 100644 index 000000000..97d489338 --- /dev/null +++ b/app/assets/javascripts/app/services/privilegesManager.js @@ -0,0 +1,307 @@ +class PrivilegesManager { + + constructor(passcodeManager, authManager, singletonManager, modelManager, storageManager, $rootScope, $compile) { + this.passcodeManager = passcodeManager; + this.authManager = authManager; + this.singletonManager = singletonManager; + this.modelManager = modelManager; + this.storageManager = storageManager; + this.$rootScope = $rootScope; + this.$compile = $compile; + + this.loadPrivileges(); + + PrivilegesManager.CredentialAccountPassword = "CredentialAccountPassword"; + PrivilegesManager.CredentialLocalPasscode = "CredentialLocalPasscode"; + + PrivilegesManager.ActionManageExtensions = "ActionManageExtensions"; + PrivilegesManager.ActionManageBackups = "ActionManageBackups"; + PrivilegesManager.ActionViewLockedNotes = "ActionViewLockedNotes"; + PrivilegesManager.ActionManagePrivileges = "ActionManagePrivileges"; + PrivilegesManager.ActionManagePasscode = "ActionManagePasscode"; + PrivilegesManager.ActionDeleteNote = "ActionDeleteNote"; + + PrivilegesManager.SessionExpiresAtKey = "SessionExpiresAtKey"; + PrivilegesManager.SessionLengthKey = "SessionLengthKey"; + + PrivilegesManager.SessionLengthNone = 0; + PrivilegesManager.SessionLengthFiveMinutes = 300; + PrivilegesManager.SessionLengthOneHour = 3600; + PrivilegesManager.SessionLengthOneWeek = 604800; + + this.availableActions = [ + PrivilegesManager.ActionManagePrivileges, + PrivilegesManager.ActionManageExtensions, + PrivilegesManager.ActionManageBackups, + PrivilegesManager.ActionManagePasscode, + PrivilegesManager.ActionViewLockedNotes, + PrivilegesManager.ActionDeleteNote + ] + + this.availableCredentials = [ + PrivilegesManager.CredentialAccountPassword, + PrivilegesManager.CredentialLocalPasscode + ]; + + this.sessionLengths = [ + PrivilegesManager.SessionLengthNone, + PrivilegesManager.SessionLengthFiveMinutes, + PrivilegesManager.SessionLengthOneHour, + PrivilegesManager.SessionLengthOneWeek, + PrivilegesManager.SessionLengthIndefinite + ] + } + + getAvailableActions() { + return this.availableActions; + } + + getAvailableCredentials() { + return this.availableCredentials; + } + + presentPrivilegesModal(action, onSuccess, onCancel) { + if(this.authenticationInProgress()) { + onCancel && onCancel(); + return; + } + + let customSuccess = () => { + onSuccess && onSuccess(); + this.currentAuthenticationElement = null; + } + + let customCancel = () => { + onCancel && onCancel(); + this.currentAuthenticationElement = null; + } + + var scope = this.$rootScope.$new(true); + scope.action = action; + scope.onSuccess = customSuccess; + scope.onCancel = customCancel; + var el = this.$compile( "" )(scope); + angular.element(document.body).append(el); + + this.currentAuthenticationElement = el; + } + + async netCredentialsForAction(action) { + let credentials = (await this.getPrivileges()).getCredentialsForAction(action); + let netCredentials = []; + + for(var cred of credentials) { + if(cred == PrivilegesManager.CredentialAccountPassword) { + if(!this.authManager.offline()) { + netCredentials.push(cred); + } + } else if(cred == PrivilegesManager.CredentialLocalPasscode) { + if(this.passcodeManager.hasPasscode()) { + netCredentials.push(cred); + } + } + } + + return netCredentials; + } + + presentPrivilegesManagementModal() { + var scope = this.$rootScope.$new(true); + var el = this.$compile( "")(scope); + angular.element(document.body).append(el); + } + + authenticationInProgress() { + return this.currentAuthenticationElement != null; + } + + async loadPrivileges() { + return new Promise((resolve, reject) => { + let prefsContentType = "SN|Privileges"; + let contentTypePredicate = new SFPredicate("content_type", "=", prefsContentType); + this.singletonManager.registerSingleton([contentTypePredicate], (resolvedSingleton) => { + this.privileges = resolvedSingleton; + if(!this.privileges.content.desktopPrivileges) { + this.privileges.content.desktopPrivileges = {}; + } + console.log("Resolved existing privs", resolvedSingleton.uuid); + resolve(resolvedSingleton); + }, (valueCallback) => { + // Safe to create. Create and return object. + var privs = new SNPrivileges({content_type: prefsContentType}); + this.modelManager.addItem(privs); + privs.setDirty(true); + this.$rootScope.sync(); + valueCallback(privs); + console.log("Creating new privs", privs.uuid); + resolve(privs); + }); + }); + } + + async getPrivileges() { + if(this.privileges) { + return this.privileges; + } else { + return this.loadPrivileges(); + } + } + + displayInfoForCredential(credential) { + let metadata = {} + + metadata[PrivilegesManager.CredentialAccountPassword] = { + label: "Account Password", + prompt: "Please enter your account password." + } + + metadata[PrivilegesManager.CredentialLocalPasscode] = { + label: "Local Passcode", + prompt: "Please enter your local passcode." + } + + return metadata[credential]; + } + + displayInfoForAction(action) { + let metadata = {}; + + metadata[PrivilegesManager.ActionManageExtensions] = { + label: "Manage Extensions" + }; + + metadata[PrivilegesManager.ActionManageBackups] = { + label: "Download/Import Backups" + }; + + metadata[PrivilegesManager.ActionViewLockedNotes] = { + label: "View Locked Notes" + }; + + metadata[PrivilegesManager.ActionManagePrivileges] = { + label: "Manage Privileges" + }; + + metadata[PrivilegesManager.ActionManagePasscode] = { + label: "Manage Passcode" + } + + metadata[PrivilegesManager.ActionDeleteNote] = { + label: "Delete Notes" + } + + return metadata[action]; + } + + getSessionLengthOptions() { + return [ + { + value: PrivilegesManager.SessionLengthNone, + label: "Don't Remember" + }, + { + value: PrivilegesManager.SessionLengthFiveMinutes, + label: "5 Minutes" + }, + { + value: PrivilegesManager.SessionLengthOneHour, + label: "1 Hour" + }, + { + value: PrivilegesManager.SessionLengthOneWeek, + label: "1 Week" + } + ] + } + + async setSessionLength(length) { + let addToNow = (seconds) => { + let date = new Date(); + date.setSeconds(date.getSeconds() + seconds); + return date; + } + + let expiresAt = addToNow(length); + + return Promise.all([ + this.storageManager.setItem(PrivilegesManager.SessionExpiresAtKey, JSON.stringify(expiresAt), StorageManager.FixedEncrypted), + this.storageManager.setItem(PrivilegesManager.SessionLengthKey, JSON.stringify(length), StorageManager.FixedEncrypted), + ]) + } + + async clearSession() { + return this.setSessionLength(PrivilegesManager.SessionLengthNone); + } + + async getSelectedSessionLength() { + let length = await this.storageManager.getItem(PrivilegesManager.SessionLengthKey, StorageManager.FixedEncrypted); + if(length) { + return JSON.parse(length); + } else { + return PrivilegesManager.SessionLengthNone; + } + } + + async getSessionExpirey() { + let expiresAt = await this.storageManager.getItem(PrivilegesManager.SessionExpiresAtKey, StorageManager.FixedEncrypted); + if(expiresAt) { + return new Date(JSON.parse(expiresAt)); + } else { + return new Date(); + } + } + + async actionRequiresPrivilege(action) { + let expiresAt = await this.getSessionExpirey(); + if(expiresAt > new Date()) { + return false; + } + return (await this.netCredentialsForAction(action)).length > 0; + } + + async savePrivileges() { + let privs = await this.getPrivileges(); + privs.setDirty(true); + this.$rootScope.sync(); + } + + async authenticateAction(action, credentialAuthMapping) { + var requiredCredentials = (await this.netCredentialsForAction(action)); + var successfulCredentials = [], failedCredentials = []; + + for(let requiredCredential of requiredCredentials) { + var passesAuth = await this._verifyAuthenticationParameters(requiredCredential, credentialAuthMapping[requiredCredential]); + if(passesAuth) { + successfulCredentials.push(requiredCredential); + } else { + failedCredentials.push(requiredCredential); + } + } + + return { + success: failedCredentials.length == 0, + successfulCredentials: successfulCredentials, + failedCredentials: failedCredentials + } + } + + async _verifyAuthenticationParameters(credential, value) { + + let verifyAccountPassword = async (password) => { + return this.authManager.verifyAccountPassword(password); + } + + let verifyLocalPasscode = async (passcode) => { + return this.passcodeManager.verifyPasscode(passcode); + } + + if(credential == PrivilegesManager.CredentialAccountPassword) { + return verifyAccountPassword(value); + } else if(credential == PrivilegesManager.CredentialLocalPasscode) { + return verifyLocalPasscode(value); + } + } + +} + +angular.module('app').service('privilegesManager', PrivilegesManager); diff --git a/app/assets/javascripts/app/services/singletonManager.js b/app/assets/javascripts/app/services/singletonManager.js index 8638cf36f..3df8cbbdd 100644 --- a/app/assets/javascripts/app/services/singletonManager.js +++ b/app/assets/javascripts/app/services/singletonManager.js @@ -108,7 +108,6 @@ class SingletonManager { var singleton = allExtantItemsMatchingPredicate[0]; singletonHandler.singleton = singleton; singletonHandler.resolutionCallback(singleton); - } } } else { diff --git a/app/assets/stylesheets/app/_editor.scss b/app/assets/stylesheets/app/_editor.scss index 65eb89e1f..aac1a7994 100644 --- a/app/assets/stylesheets/app/_editor.scss +++ b/app/assets/stylesheets/app/_editor.scss @@ -1,4 +1,11 @@ $heading-height: 75px; + +#editor-column { + .locked { + opacity: 0.8; + } +} + .editor { flex: 1 50%; display: flex; @@ -7,10 +14,6 @@ $heading-height: 75px; background-color: var(--sn-stylekit-background-color); } -.locked { - opacity: 0.8; -} - #editor-title-bar { width: 100%; diff --git a/app/assets/stylesheets/app/_lock-screen.scss b/app/assets/stylesheets/app/_lock-screen.scss index 5e135881e..f5faed28e 100644 --- a/app/assets/stylesheets/app/_lock-screen.scss +++ b/app/assets/stylesheets/app/_lock-screen.scss @@ -14,8 +14,8 @@ z-index: $z-index-lock-screen; background-color: var(--sn-stylekit-contrast-background-color); color: var(--sn-stylekit-foreground-color); - font-size: 16px; display: flex; + flex-direction: column; align-items: center; justify-content: center; @@ -29,6 +29,7 @@ .sk-panel { width: 315px; flex-grow: 0; + border-radius: 0; .sk-panel-header { justify-content: center; @@ -36,9 +37,6 @@ } #passcode-reset { - margin-top: 18px; text-align: center; - width: 100%; - font-size: 13px; } } diff --git a/app/assets/stylesheets/app/_modals.scss b/app/assets/stylesheets/app/_modals.scss index bb35bbef6..09ac067f2 100644 --- a/app/assets/stylesheets/app/_modals.scss +++ b/app/assets/stylesheets/app/_modals.scss @@ -11,6 +11,42 @@ } } +#privileges-modal { + width: 700px; + + table { + margin-bottom: 12px; + width: 100%; + overflow: auto; + border-collapse: collapse; + border-spacing: 0px; + border-color: var(--sn-stylekit-contrast-border-color); + background-color: var(--sn-stylekit-background-color); + color: var(--sn-stylekit-contrast-foreground-color); + + th, td { + padding: 6px 13px; + border: 1px solid var(--sn-stylekit-contrast-border-color); + } + + tr:nth-child(2n) { + background-color: var(--sn-stylekit-contrast-background-color); + } + } + + th { + text-align: center; + font-weight: normal; + } + + .priv-header { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } +} + #password-wizard { font-size: 16px; } @@ -147,9 +183,10 @@ // Not sure yet if totally required. // Update: The extensions manager doesn't display correctly without it // flex-grow: 1 should fix that. - + flex-grow: 1; width: 100%; height: 100%; + background-color: transparent; } } diff --git a/app/assets/templates/directives/account-menu.html.haml b/app/assets/templates/directives/account-menu.html.haml index 9cff2a4b0..436ef6a74 100644 --- a/app/assets/templates/directives/account-menu.html.haml +++ b/app/assets/templates/directives/account-menu.html.haml @@ -27,9 +27,6 @@ %a.sk-panel-row{"ng-click" => "formData.showAdvanced = !formData.showAdvanced"} Advanced Options - .sk-notification.neutral{"ng-if" => "formData.showRegister"} - .sk-notification-title No Password Reset. - .sk-notification-text Because your notes are encrypted using your password, Standard Notes does not have a password reset option. You cannot forget your password. .sk-notification.unpadded.contrast.advanced-options.sk-panel-row{"ng-if" => "formData.showAdvanced"} .sk-panel-column.stretch @@ -47,12 +44,16 @@ %button.sk-button.info.featured{"type" => "submit", "ng-disabled" => "formData.authenticating"} .sk-label {{formData.showLogin ? "Sign In" : "Register"}} + .sk-notification.neutral{"ng-if" => "formData.showRegister"} + .sk-notification-title No Password Reset. + .sk-notification-text Because your notes are encrypted using your password, Standard Notes does not have a password reset option. You cannot forget your password. + .sk-panel-row %label %input{"type" => "checkbox", "ng-model" => "formData.ephemeral", "ng-true-value" => "false", "ng-false-value" => "true"} Stay signed in - .sk-panel-row %label{"ng-if" => "notesAndTagsCount() > 0"} + .sk-panel-row %input{"type" => "checkbox", "ng-model" => "formData.mergeLocal", "ng-bind" => "true", "ng-change" => "mergeLocalChanged()"} Merge local data ({{notesAndTagsCount()}} notes and tags) @@ -88,7 +89,10 @@ .sk-panel-row - %a.sk-a.info.sk-panel-row.condensed{"ng-click" => "openPasswordWizard('change-pw')"} Change Password + %a.sk-a.info.sk-panel-row.condensed{"ng-click" => "openPasswordWizard('change-pw')"} + Change Password + %a.sk-a.info.sk-panel-row.condensed{"ng-show" => "user", "ng-click" => "openPrivilegesModal('')"} + Manage Privileges %a.sk-panel-row.justify-left.condensed.success{"ng-if" => "securityUpdateAvailable", "ng-click" => "openPasswordWizard('upgrade-security')"} .inline.sk-circle.small.success.mr-8 .inline Security Update Available @@ -123,19 +127,31 @@ %a.neutral.sk-a.sk-panel-row{"ng-click" => "formData.showPasscodeForm = false"} Cancel %div{"ng-if" => "hasPasscode() && !formData.showPasscodeForm"} - .sk-panel-row - %p.sk-p - Passcode lock is enabled. - %span{"ng-if" => "isDesktopApplication()"} Your passcode will be required on new sessions after app quit. - .sk-panel-row.justify-left - .sk-horizontal-group - %a.sk-a.info{"ng-click" => "changePasscodePressed()"} Change Passcode - %a.sk-a.danger{"ng-click" => "removePasscodePressed()"} Remove Passcode + .sk-p + Passcode lock is enabled. + %span{"ng-if" => "isDesktopApplication()"} Your passcode will be required on new sessions after app quit. + .sk-notification.contrast + .sk-notification-title Options + .sk-notification-text + .sk-panel-row + .sk-horizontal-group + .sk-h4.sk-bold Autolock + %a.sk-a.info{"ng-repeat" => "option in passcodeAutoLockOptions", "ng-click" => "selectAutoLockInterval(option.value)", + "ng-class" => "{'info boxed' : option.value == selectedAutoLockInterval}"} + {{option.label}} + .sk-p The autolock timer begins when the window or tab loses focus. + .sk-panel-row + %a.sk-a.info.sk-panel-row.condensed{"ng-show" => "!user", "ng-click" => "openPrivilegesModal('')"} Manage Privileges + %a.sk-a.info.sk-panel-row.condensed{"ng-click" => "changePasscodePressed()"} Change Passcode + %a.sk-a.danger.sk-panel-row.condensed{"ng-click" => "removePasscodePressed()"} Remove Passcode .sk-panel-section{"ng-if" => "!importData.loading"} .sk-panel-section-title Data Backups + .sk-p + Download a backup of all your data. + .sk-panel-row %form.sk-panel-form.sk-panel-row{"ng-if" => "encryptedBackupsAvailable()"} .sk-input-group %label @@ -151,7 +167,7 @@ %label.sk-button.info %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"} - .sk-label Import From Backup + .sk-label Import Backup %span{"ng-if" => "isDesktopApplication()"} Backups are automatically created on desktop and can be managed via the "Backups" top-level menu. diff --git a/app/assets/templates/directives/menu-row.html.haml b/app/assets/templates/directives/menu-row.html.haml index 009c0d236..ca3ffc399 100644 --- a/app/assets/templates/directives/menu-row.html.haml +++ b/app/assets/templates/directives/menu-row.html.haml @@ -1,7 +1,7 @@ .sk-menu-panel-row.row{"ng-attr-title" => "{{desc}}", "ng-click" => "onClick($event)"} .sk-menu-panel-column .left - .sk-menu-panel-column{"ng-if" => "circle"} + .sk-menu-panel-column{"ng-if" => "circle && (!circleAlign || circleAlign == 'left')"} .sk-circle.small{"ng-class" => "circle"} .sk-menu-panel-column{"ng-class" => "{'faded' : faded || disabled}"} .sk-label @@ -13,6 +13,9 @@ %menu-row{"ng-repeat" => "row in subRows", "action" => "row.onClick()", "label" => "row.label", "subtitle" => "row.subtitle", "spinner-class" => "row.spinnerClass"} + .sk-menu-panel-column{"ng-if" => "circle && circleAlign == 'right'"} + .sk-circle.small{"ng-class" => "circle"} + .sk-menu-panel-column{"ng-if" => "hasButton"} .sk-button{"ng-click" => "clickButton($event)", "ng-class" => "buttonClass"} .sk-label {{buttonText}} diff --git a/app/assets/templates/directives/permissions-modal.html.haml b/app/assets/templates/directives/permissions-modal.html.haml index 9c797a243..9628eb9ad 100644 --- a/app/assets/templates/directives/permissions-modal.html.haml +++ b/app/assets/templates/directives/permissions-modal.html.haml @@ -1,6 +1,6 @@ -.background{"ng-click" => "deny()"} +.sk-modal-background{"ng-click" => "deny()"} -.content#permissions-modal +.sk-modal-content#permissions-modal .sn-component .sk-panel .sk-panel-header diff --git a/app/assets/templates/directives/privileges-auth-modal.html.haml b/app/assets/templates/directives/privileges-auth-modal.html.haml new file mode 100644 index 000000000..c48f6a9d2 --- /dev/null +++ b/app/assets/templates/directives/privileges-auth-modal.html.haml @@ -0,0 +1,29 @@ +.sk-modal-background{"ng-click" => "cancel()"} + +.sk-modal-content#privileges-modal + .sn-component + .sk-panel + .sk-panel-header + .sk-panel-header-title Authentication Required + %a.close-button.info{"ng-click" => "cancel()"} Cancel + .sk-panel-content + .sk-panel-section + %div{"ng-repeat" => "credential in requiredCredentials"} + .sk-p.sk-bold.sk-panel-row + %strong {{promptForCredential(credential)}} + .sk-panel-row + %input.sk-input.contrast{"type" => "password", "ng-model" => "authenticationParameters[credential]", + "sn-autofocus" => "true", "should-focus" => "$index == 0", "sn-enter" => "submit()"} + .sk-panel-row + %label.sk-label.danger{"ng-if" => "isCredentialInFailureState(credential)"} Invalid authentication. Please try again. + .sk-panel-row + .sk-panel-row + .sk-horizontal-group + .sk-p.sk-bold Remember For + %a.sk-a.info{"ng-repeat" => "option in sessionLengthOptions", "ng-click" => "selectSessionLength(option.value)", + "ng-class" => "{'info boxed' : option.value == selectedSessionLength}"} + {{option.label}} + + .sk-panel-footer.extra-padding + .sk-button.info.big.block.bold{"ng-click" => "submit()"} + .sk-label Submit diff --git a/app/assets/templates/directives/privileges-management-modal.html.haml b/app/assets/templates/directives/privileges-management-modal.html.haml new file mode 100644 index 000000000..03a4bfb11 --- /dev/null +++ b/app/assets/templates/directives/privileges-management-modal.html.haml @@ -0,0 +1,40 @@ +.sk-modal-background{"ng-click" => "cancel()"} + +.sk-modal-content#privileges-modal + .sn-component + .sk-panel + .sk-panel-header + .sk-panel-header-title Manage Privileges + %a.sk-a.close-button.info{"ng-click" => "cancel()"} Done + .sk-panel-content + .sk-panel-section + %table.sk-table + %thead + %tr + %th + %th{"ng-repeat" => "cred in availableCredentials"} + .priv-header + %strong {{credentialDisplayInfo[cred].label}} + .sk-p.font-small{"style" => "margin-top: 2px", "ng-show" => "!credentialDisplayInfo[cred].availability"} Not Configured + %tbody + %tr{"ng-repeat" => "action in availableActions"} + %td + .sk-p {{displayInfoForAction(action)}} + %th{"ng-repeat" => "credential in availableCredentials"} + %input{"type" => "checkbox", "ng-disabled" => "!credentialDisplayInfo[credential].availability", "ng-checked" => "isCredentialRequiredForAction(action, credential)", "ng-click" => "checkboxValueChanged(action, credential)"} + + .sk-panel-section{"ng-if" => "sessionExpirey && !sessionExpired"} + .sk-p You will not be asked to authenticate until {{sessionExpirey}}. + %a.sk-a {"ng-click" => "clearSession()"} Clear Session + .sk-panel-footer + .sk-h2 About Privileges + .sk-panel-section.no-bottom-pad + .text-content + %p + Privileges represent interface level authentication for accessing certain items and features. + Note that when your application is unlocked, your data exists in temporary memory in an unencrypted state. + Privileges are meant to protect against unwanted access in the event of an unlocked application, but do not affect data encryption state. + %p + Privileges sync across your other devices (not including mobile); however, note that if you require + a "Local Passcode" privilege, and another device does not have a local passcode set up, the local passcode + requirement will be ignored on that device. diff --git a/app/assets/templates/editor.html.haml b/app/assets/templates/editor.html.haml index dfea92eed..ec5c34da7 100644 --- a/app/assets/templates/editor.html.haml +++ b/app/assets/templates/editor.html.haml @@ -34,7 +34,7 @@ %menu-row{"label" => "ctrl.note.pinned ? 'Unpin' : 'Pin'", "action" => "ctrl.selectedMenuItem(true); ctrl.togglePin()", "desc" => "'Pin or unpin a note from the top of your list'"} %menu-row{"label" => "ctrl.note.archived ? 'Unarchive' : 'Archive'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleArchiveNote()", "desc" => "'Archive or unarchive a note from your Archived system tag'"} %menu-row{"label" => "ctrl.note.locked ? 'Unlock' : 'Lock'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleLockNote()", "desc" => "'Locking notes prevents unintentional editing'"} - %menu-row{"label" => "ctrl.note.content.hidePreview ? 'Unhide Preview' : 'Hide Preview'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleNotePreview()", "desc" => "'Hide or unhide the note preview from the list of notes'"} + %menu-row{"label" => "'Preview'", "circle" => "ctrl.note.content.hidePreview ? 'danger' : 'success'", "circle-align" => "'right'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleNotePreview()", "desc" => "'Hide or unhide the note preview from the list of notes'"} %menu-row{"label" => "'Delete'", "action" => "ctrl.selectedMenuItem(); ctrl.deleteNote()", "desc" => "'Delete this note permanently from all your devices'"} .sk-menu-panel-section diff --git a/app/assets/templates/footer.html.haml b/app/assets/templates/footer.html.haml index 41ac65bb7..40717084e 100644 --- a/app/assets/templates/footer.html.haml +++ b/app/assets/templates/footer.html.haml @@ -1,7 +1,7 @@ .sn-component #footer-bar.sk-app-bar.no-edges .left - .sk-app-bar-item{"ng-click" => "ctrl.accountMenuPressed()", "click-outside" => "ctrl.showAccountMenu = false;", "is-open" => "ctrl.showAccountMenu"} + .sk-app-bar-item{"ng-click" => "ctrl.accountMenuPressed()", "click-outside" => "ctrl.clickOutsideAccountMenu()", "is-open" => "ctrl.showAccountMenu"} .sk-app-bar-item-column .sk-circle.small{"ng-class" => "ctrl.error ? 'danger' : (ctrl.getUser() ? 'info' : 'neutral')"} .sk-app-bar-item-column @@ -39,6 +39,6 @@ .sk-app-bar-item{"ng-if" => "!ctrl.offline", "ng-click" => "ctrl.refreshData()"} .sk-label Refresh - .sk-app-bar-item#lock-item{"ng-if" => "ctrl.hasPasscode()"} + .sk-app-bar-item#lock-item{"ng-if" => "ctrl.hasPasscode()", "title" => "Locks application and wipes unencrypted data from memory."} .sk-label %i.icon.ion-locked#footer-lock-icon{"ng-if" => "ctrl.hasPasscode()", "ng-click" => "ctrl.lockApp()"} diff --git a/app/assets/templates/lock-screen.html.haml b/app/assets/templates/lock-screen.html.haml index 890b495b2..222e6b704 100644 --- a/app/assets/templates/lock-screen.html.haml +++ b/app/assets/templates/lock-screen.html.haml @@ -13,11 +13,12 @@ %button.sk-button.info{"type" => "submit"} .sk-label Unlock - #passcode-reset - %a.sk-a.neutral{"ng-if" => "!formData.showRecovery", "ng-click" => "forgotPasscode()"} Forgot Passcode? + .sk-panel-footer + #passcode-reset + %a.sk-a.neutral{"ng-if" => "!formData.showRecovery", "ng-click" => "forgotPasscode()"} Forgot? - %div{"ng-if" => "formData.showRecovery"} - %p - If you forgot your local passcode, your only option is to clear all your local data from this device - and sign back in to your account. - %a.sk-a.danger{"ng-click" => "beginDeleteData()"} Delete Local Data + %div{"ng-if" => "formData.showRecovery"} + .sk-p + If you forgot your local passcode, your only option is to clear all your local data from this device + and sign back in to your account. + %a.sk-a.danger{"ng-click" => "beginDeleteData()"} Delete Local Data diff --git a/app/assets/templates/notes.html.haml b/app/assets/templates/notes.html.haml index e566e45ea..7e82d7719 100644 --- a/app/assets/templates/notes.html.haml +++ b/app/assets/templates/notes.html.haml @@ -58,9 +58,11 @@ .faded {{note.savedTagsString || note.tagsString()}} .name{"ng-if" => "note.title"} + %span.locked.tinted{"ng-if" => "note.locked", "ng-class" => "{'tinted-selected' : ctrl.selectedNote == note}"} + %i.icon.ion-locked.medium-text {{note.title}} - .note-preview{"ng-if" => "!ctrl.hideNotePreview && !note.content.hidePreview"} + .note-preview{"ng-if" => "!ctrl.hideNotePreview && !note.content.hidePreview && !note.locked"} .html-preview{"ng-if" => "note.content.preview_html", "ng-bind-html" => "note.content.preview_html"} .plain-preview{"ng-if" => "!note.content.preview_html && note.content.preview_plain"} {{note.content.preview_plain}} .default-preview{"ng-if" => "!note.content.preview_html && !note.content.preview_plain"} {{note.text}} diff --git a/package-lock.json b/package-lock.json index 31193042a..5bf46c70b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "standard-notes-web", - "version": "2.3.18", + "version": "2.4.0-beta1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index c533efa89..12eb97359 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "standard-notes-web", - "version": "2.3.18", + "version": "2.4.0-beta1", "license": "AGPL-3.0-or-later", "repository": { "type": "git",