Merge privileges
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -41,7 +41,6 @@ class LockScreen {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
angular.module('app').directive('lockScreen', () => new LockScreen);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
15
app/assets/javascripts/app/directives/functional/snEnter.js
Normal file
15
app/assets/javascripts/app/directives/functional/snEnter.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ class MenuRow {
|
||||
this.scope = {
|
||||
action: "&",
|
||||
circle: "=",
|
||||
circleAlign: "=",
|
||||
label: "=",
|
||||
subtitle: "=",
|
||||
hasButton: "=",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
34
app/assets/javascripts/app/models/privileges.js
Normal file
34
app/assets/javascripts/app/models/privileges.js
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ class HttpManager extends SFHttpManager {
|
||||
super($timeout);
|
||||
|
||||
this.setJWTRequestHandler(async () => {
|
||||
return storageManager.getItem("jwt");;
|
||||
return storageManager.getItem("jwt");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
307
app/assets/javascripts/app/services/privilegesManager.js
Normal file
307
app/assets/javascripts/app/services/privilegesManager.js
Normal file
@@ -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( "<privileges-auth-modal action='action' on-success='onSuccess' on-cancel='onCancel' class='sk-modal'></privileges-auth-modal>" )(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( "<privileges-management-modal class='sk-modal'></privileges-management-modal>")(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);
|
||||
@@ -108,7 +108,6 @@ class SingletonManager {
|
||||
var singleton = allExtantItemsMatchingPredicate[0];
|
||||
singletonHandler.singleton = singleton;
|
||||
singletonHandler.resolutionCallback(singleton);
|
||||
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "standard-notes-web",
|
||||
"version": "2.3.18",
|
||||
"version": "2.4.0-beta1",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user