feat: custom challenges
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
/** @generic */
|
/** @generic */
|
||||||
export const STRING_SESSION_EXPIRED = "Your session has expired. New changes will not be pulled in. Please sign out and sign back in to refresh your session.";
|
export const STRING_SESSION_EXPIRED = "Your session has expired. New changes will not be pulled in. Please sign in to refresh your session.";
|
||||||
export const STRING_DEFAULT_FILE_ERROR = "Please use FileSafe or the Bold Editor to attach images and files. Learn more at standardnotes.org/filesafe.";
|
export const STRING_DEFAULT_FILE_ERROR = "Please use FileSafe or the Bold Editor to attach images and files. Learn more at standardnotes.org/filesafe.";
|
||||||
export const STRING_GENERIC_SYNC_ERROR = "There was an error syncing. Please try again. If all else fails, try signing out and signing back in.";
|
export const STRING_GENERIC_SYNC_ERROR = "There was an error syncing. Please try again. If all else fails, try signing out and signing back in.";
|
||||||
export function StringSyncException(data: any) {
|
export function StringSyncException(data: any) {
|
||||||
@@ -46,10 +46,6 @@ export const STRING_INVALID_IMPORT_FILE = "Unable to open file. Ensure it is a p
|
|||||||
export function StringImportError(errorCount: number) {
|
export function StringImportError(errorCount: number) {
|
||||||
return `Import complete. ${errorCount} items were not imported because there was an error decrypting them. Make sure the password is correct and try again.`;
|
return `Import complete. ${errorCount} items were not imported because there was an error decrypting them. Make sure the password is correct and try again.`;
|
||||||
}
|
}
|
||||||
export const STRING_ENTER_ACCOUNT_PASSCODE = 'Enter your application passcode to unlock the application';
|
|
||||||
export const STRING_ENTER_ACCOUNT_PASSWORD = 'Enter your account password';
|
|
||||||
export const STRING_ENTER_PASSCODE_FOR_MIGRATION = 'Your application passcode is required to perform an upgrade of your local data storage structure.';
|
|
||||||
export const STRING_ENTER_PASSCODE_FOR_LOGIN_REGISTER = 'Enter your application passcode before signing in or registering';
|
|
||||||
export const STRING_STORAGE_UPDATE = 'Storage Update';
|
export const STRING_STORAGE_UPDATE = 'Storage Update';
|
||||||
export const STRING_AUTHENTICATION_REQUIRED = 'Authentication Required';
|
export const STRING_AUTHENTICATION_REQUIRED = 'Authentication Required';
|
||||||
export const STRING_UNSUPPORTED_BACKUP_FILE_VERSION = 'This backup file was created using an unsupported version of the application and cannot be imported here. Please update your application and try again.';
|
export const STRING_UNSUPPORTED_BACKUP_FILE_VERSION = 'This backup file was created using an unsupported version of the application and cannot be imported here. Please update your application and try again.';
|
||||||
|
|||||||
@@ -268,9 +268,9 @@ class ApplicationViewCtrl extends PureViewCtrl {
|
|||||||
this.lastAlertShownTimeStamp = Date.now();
|
this.lastAlertShownTimeStamp = Date.now();
|
||||||
this.showingInvalidSessionAlert = true;
|
this.showingInvalidSessionAlert = true;
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await alertDialog({
|
// await alertDialog({
|
||||||
text: STRING_SESSION_EXPIRED
|
// text: STRING_SESSION_EXPIRED
|
||||||
});
|
// });
|
||||||
this.showingInvalidSessionAlert = false;
|
this.showingInvalidSessionAlert = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,28 @@
|
|||||||
.sn-component
|
.sn-component
|
||||||
.sk-panel
|
.sk-panel
|
||||||
.sk-panel-header
|
.sk-panel-header
|
||||||
.sk-panel-header-title {{ctrl.title}}
|
.sk-panel-header-title {{ctrl.modalTitle}}
|
||||||
.sk-panel-content
|
.sk-panel-content
|
||||||
.sk-panel-section
|
.sk-panel-section
|
||||||
div(ng-repeat="type in ctrl.state.types")
|
.sk-p.sk-panel-row.centered.prompt
|
||||||
.sk-p.sk-panel-row.centered.prompt
|
strong {{ctrl.state.title}}
|
||||||
strong {{ctrl.promptForChallenge(type)}}
|
.sk-p.sk-panel-row.centered.subprompt(ng-if='ctrl.state.subtitle')
|
||||||
|
| {{ctrl.state.subtitle}}
|
||||||
|
.sk-panel-section
|
||||||
|
div(ng-repeat="prompt in ctrl.state.prompts track by prompt.id")
|
||||||
.sk-panel-row
|
.sk-panel-row
|
||||||
input.sk-input.contrast(
|
input.sk-input.contrast(
|
||||||
ng-model="ctrl.state.values[type].value"
|
ng-model="ctrl.state.values[prompt.id].value"
|
||||||
should-focus="$index == 0"
|
should-focus="$index == 0"
|
||||||
sn-autofocus="true"
|
sn-autofocus="true"
|
||||||
sn-enter="ctrl.submit()" ,
|
sn-enter="ctrl.submit()" ,
|
||||||
ng-change="ctrl.onTextValueChange(type)"
|
ng-change="ctrl.onTextValueChange(prompt)"
|
||||||
type="password"
|
ng-attr-type="{{prompt.secureTextEntry ? 'password' : 'text'}}",
|
||||||
|
ng-attr-placeholder="{{prompt.placeholder}}"
|
||||||
)
|
)
|
||||||
.sk-panel-row.centered
|
.sk-panel-row.centered
|
||||||
label.sk-label.danger(
|
label.sk-label.danger(
|
||||||
ng-if="ctrl.state.values[type].invalid"
|
ng-if="ctrl.state.values[prompt.id].invalid"
|
||||||
) Invalid authentication. Please try again.
|
) Invalid authentication. Please try again.
|
||||||
.sk-panel-footer.extra-padding
|
.sk-panel-footer.extra-padding
|
||||||
.sk-button.info.big.block.bold(
|
.sk-button.info.big.block.bold(
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import { WebApplication } from '@/ui_models/application';
|
import { WebApplication } from '@/ui_models/application';
|
||||||
import template from './challenge-modal.pug';
|
import template from './challenge-modal.pug';
|
||||||
import {
|
import {
|
||||||
ChallengeType,
|
|
||||||
ChallengeValue,
|
ChallengeValue,
|
||||||
removeFromArray,
|
removeFromArray,
|
||||||
Challenge,
|
Challenge,
|
||||||
ChallengeReason,
|
ChallengeReason,
|
||||||
|
ChallengePrompt
|
||||||
} from 'snjs';
|
} from 'snjs';
|
||||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||||
import { WebDirective } from '@/types';
|
import { WebDirective } from '@/types';
|
||||||
import { confirmDialog } from '@/services/alertService';
|
import { confirmDialog } from '@/services/alertService';
|
||||||
import {
|
import {
|
||||||
STRING_SIGN_OUT_CONFIRMATION,
|
STRING_SIGN_OUT_CONFIRMATION,
|
||||||
STRING_ENTER_ACCOUNT_PASSCODE,
|
|
||||||
STRING_ENTER_ACCOUNT_PASSWORD,
|
|
||||||
STRING_ENTER_PASSCODE_FOR_MIGRATION,
|
|
||||||
STRING_STORAGE_UPDATE,
|
STRING_STORAGE_UPDATE,
|
||||||
STRING_AUTHENTICATION_REQUIRED,
|
STRING_AUTHENTICATION_REQUIRED,
|
||||||
STRING_ENTER_PASSCODE_FOR_LOGIN_REGISTER,
|
|
||||||
} from '@/strings';
|
} from '@/strings';
|
||||||
|
|
||||||
type InputValue = {
|
type InputValue = {
|
||||||
|
prompt: ChallengePrompt
|
||||||
value: string
|
value: string
|
||||||
invalid: boolean
|
invalid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Values = Record<ChallengeType, InputValue>
|
type Values = Record<number, InputValue>
|
||||||
|
|
||||||
type ChallengeModalState = {
|
type ChallengeModalState = {
|
||||||
types: ChallengeType[]
|
prompts: ChallengePrompt[]
|
||||||
values: Partial<Values>
|
values: Partial<Values>
|
||||||
processing: boolean,
|
processing: boolean,
|
||||||
forgotPasscode: boolean,
|
forgotPasscode: boolean,
|
||||||
showForgotPasscodeLink: boolean,
|
showForgotPasscodeLink: boolean,
|
||||||
|
processingPrompts: ChallengePrompt[],
|
||||||
|
hasAccount: boolean,
|
||||||
|
title: string,
|
||||||
|
subtitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChallengeModalCtrl extends PureViewCtrl {
|
class ChallengeModalCtrl extends PureViewCtrl<{}, ChallengeModalState> {
|
||||||
private $element: JQLite
|
private $element: JQLite
|
||||||
private processingTypes: ChallengeType[] = []
|
|
||||||
application!: WebApplication
|
application!: WebApplication
|
||||||
challenge!: Challenge
|
challenge!: Challenge
|
||||||
private cancelable = false
|
private cancelable = false
|
||||||
@@ -58,14 +58,15 @@ class ChallengeModalCtrl extends PureViewCtrl {
|
|||||||
$onInit() {
|
$onInit() {
|
||||||
super.$onInit();
|
super.$onInit();
|
||||||
const values = {} as Values;
|
const values = {} as Values;
|
||||||
const types = this.challenge.types;
|
const prompts = this.challenge.prompts;
|
||||||
for (const type of types) {
|
for (const prompt of prompts) {
|
||||||
values[type] = {
|
values[prompt.id] = {
|
||||||
|
prompt,
|
||||||
value: '',
|
value: '',
|
||||||
invalid: false
|
invalid: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let showForgotPasscodeLink: boolean;
|
let showForgotPasscodeLink = false;
|
||||||
switch (this.challenge.reason) {
|
switch (this.challenge.reason) {
|
||||||
case ChallengeReason.ApplicationUnlock:
|
case ChallengeReason.ApplicationUnlock:
|
||||||
showForgotPasscodeLink = true;
|
showForgotPasscodeLink = true;
|
||||||
@@ -86,29 +87,42 @@ class ChallengeModalCtrl extends PureViewCtrl {
|
|||||||
}
|
}
|
||||||
this.cancelable = !showForgotPasscodeLink
|
this.cancelable = !showForgotPasscodeLink
|
||||||
this.setState({
|
this.setState({
|
||||||
types,
|
prompts,
|
||||||
values,
|
values,
|
||||||
processing: false,
|
processing: false,
|
||||||
forgotPasscode: false,
|
forgotPasscode: false,
|
||||||
showForgotPasscodeLink,
|
showForgotPasscodeLink,
|
||||||
hasAccount: this.application.hasAccount(),
|
hasAccount: this.application.hasAccount(),
|
||||||
|
title: this.challenge.title,
|
||||||
|
subtitle: this.challenge.subtitle,
|
||||||
|
processingPrompts: []
|
||||||
});
|
});
|
||||||
this.application.setChallengeCallbacks({
|
this.application.addChallengeObserver(
|
||||||
challenge: this.challenge,
|
this.challenge,
|
||||||
onValidValue: (value) => {
|
{
|
||||||
this.getState().values[value.type]!.invalid = false;
|
onValidValue: (value) => {
|
||||||
removeFromArray(this.processingTypes, value.type);
|
this.getState().values[value.prompt.id]!.invalid = false;
|
||||||
this.reloadProcessingStatus();
|
removeFromArray(this.state.processingPrompts, value.prompt);
|
||||||
},
|
this.reloadProcessingStatus();
|
||||||
onInvalidValue: (value) => {
|
},
|
||||||
this.getState().values[value.type]!.invalid = true;
|
onInvalidValue: (value) => {
|
||||||
removeFromArray(this.processingTypes, value.type);
|
this.getState().values[value.prompt.id]!.invalid = true;
|
||||||
this.reloadProcessingStatus();
|
/** If custom validation, treat all values together and not individually */
|
||||||
},
|
if (!value.prompt.validates) {
|
||||||
onComplete: () => {
|
this.setState({ processingPrompts: [] });
|
||||||
this.dismiss();
|
} else {
|
||||||
},
|
removeFromArray(this.state.processingPrompts, value.prompt);
|
||||||
});
|
}
|
||||||
|
this.reloadProcessingStatus();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
this.dismiss();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this.dismiss();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit() {
|
deinit() {
|
||||||
@@ -118,12 +132,12 @@ class ChallengeModalCtrl extends PureViewCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reloadProcessingStatus() {
|
reloadProcessingStatus() {
|
||||||
this.setState({
|
return this.setState({
|
||||||
processing: this.processingTypes.length > 0
|
processing: this.state.processingPrompts.length > 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get title(): string {
|
get modalTitle(): string {
|
||||||
if (this.challenge.reason === ChallengeReason.Migration) {
|
if (this.challenge.reason === ChallengeReason.Migration) {
|
||||||
return STRING_STORAGE_UPDATE;
|
return STRING_STORAGE_UPDATE;
|
||||||
} else {
|
} else {
|
||||||
@@ -131,21 +145,6 @@ class ChallengeModalCtrl extends PureViewCtrl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promptForChallenge(challenge: ChallengeType): string {
|
|
||||||
if (challenge === ChallengeType.LocalPasscode) {
|
|
||||||
switch (this.challenge.reason) {
|
|
||||||
case ChallengeReason.Migration:
|
|
||||||
return STRING_ENTER_PASSCODE_FOR_MIGRATION;
|
|
||||||
case ChallengeReason.ResaveRootKey:
|
|
||||||
return STRING_ENTER_PASSCODE_FOR_LOGIN_REGISTER;
|
|
||||||
default:
|
|
||||||
return STRING_ENTER_ACCOUNT_PASSCODE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return STRING_ENTER_ACCOUNT_PASSWORD;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyLocalData() {
|
async destroyLocalData() {
|
||||||
if (await confirmDialog({
|
if (await confirmDialog({
|
||||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
text: STRING_SIGN_OUT_CONFIRMATION,
|
||||||
@@ -169,18 +168,18 @@ class ChallengeModalCtrl extends PureViewCtrl {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onTextValueChange(challenge: ChallengeType) {
|
onTextValueChange(prompt: ChallengePrompt) {
|
||||||
const values = this.getState().values;
|
const values = this.getState().values;
|
||||||
values[challenge]!.invalid = false;
|
values[prompt.id]!.invalid = false;
|
||||||
this.setState({ values });
|
this.setState({ values });
|
||||||
}
|
}
|
||||||
|
|
||||||
validate() {
|
validate() {
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const type of this.getState().types) {
|
for (const prompt of this.getState().prompts) {
|
||||||
const value = this.getState().values[type];
|
const value = this.getState().values[prompt.id];
|
||||||
if (!value || value.value.length === 0) {
|
if (!value || value.value.length === 0) {
|
||||||
this.getState().values[type]!.invalid = true;
|
this.getState().values[prompt.id]!.invalid = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return failed.length === 0;
|
return failed.length === 0;
|
||||||
@@ -191,22 +190,32 @@ class ChallengeModalCtrl extends PureViewCtrl {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.setState({ processing: true });
|
await this.setState({ processing: true });
|
||||||
const values = [];
|
const values: ChallengeValue[] = [];
|
||||||
for (const key of Object.keys(this.getState().values)) {
|
for (const inputValue of Object.values(this.getState().values)) {
|
||||||
const type = Number(key) as ChallengeType;
|
if (inputValue!.invalid) {
|
||||||
if (this.getState().values[type]!.invalid) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const rawValue = this.getState().values[type]!.value;
|
const rawValue = inputValue!!.value;
|
||||||
const value = new ChallengeValue(type, rawValue);
|
const value = new ChallengeValue(inputValue!.prompt, rawValue);
|
||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
this.processingTypes = values.map((v) => v.type);
|
const processingPrompts = values.map((v) => v.prompt);
|
||||||
if (values.length > 0) {
|
await this.setState({
|
||||||
this.application.submitValuesForChallenge(this.challenge, values);
|
processingPrompts: processingPrompts,
|
||||||
} else {
|
processing: processingPrompts.length > 0
|
||||||
this.setState({ processing: false });
|
})
|
||||||
}
|
/**
|
||||||
|
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
||||||
|
* updates the UI to change processing state, before we enter into UI blocking operation
|
||||||
|
* (crypto key generation)
|
||||||
|
*/
|
||||||
|
this.$timeout(() => {
|
||||||
|
if (values.length > 0) {
|
||||||
|
this.application.submitValuesForChallenge(this.challenge, values);
|
||||||
|
} else {
|
||||||
|
this.setState({ processing: false });
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
dismiss() {
|
dismiss() {
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ declare type StatusCallback = (string: string) => void;
|
|||||||
export declare class StatusManager {
|
export declare class StatusManager {
|
||||||
private statuses;
|
private statuses;
|
||||||
private observers;
|
private observers;
|
||||||
statusFromString(string: string): {
|
replaceStatusWithString(status: FooterStatus, string: string): {
|
||||||
|
string: string;
|
||||||
|
};
|
||||||
|
addStatusFromString(string: string): {
|
||||||
string: string;
|
string: string;
|
||||||
};
|
};
|
||||||
replaceStatusWithString(status: FooterStatus, string: string): FooterStatus;
|
|
||||||
addStatusFromString(string: string): FooterStatus;
|
|
||||||
addStatus(status: FooterStatus): FooterStatus;
|
|
||||||
removeStatus(status: FooterStatus): undefined;
|
removeStatus(status: FooterStatus): undefined;
|
||||||
getStatusString(): string;
|
|
||||||
notifyObservers(): void;
|
|
||||||
addStatusObserver(callback: StatusCallback): () => void;
|
addStatusObserver(callback: StatusCallback): () => void;
|
||||||
|
private notifyObservers;
|
||||||
|
private getStatusString;
|
||||||
}
|
}
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** @generic */
|
/** @generic */
|
||||||
export declare const STRING_SESSION_EXPIRED = "Your session has expired. New changes will not be pulled in. Please sign out and sign back in to refresh your session.";
|
export declare const STRING_SESSION_EXPIRED = "Your session has expired. New changes will not be pulled in. Please sign in to refresh your session.";
|
||||||
export declare const STRING_DEFAULT_FILE_ERROR = "Please use FileSafe or the Bold Editor to attach images and files. Learn more at standardnotes.org/filesafe.";
|
export declare const STRING_DEFAULT_FILE_ERROR = "Please use FileSafe or the Bold Editor to attach images and files. Learn more at standardnotes.org/filesafe.";
|
||||||
export declare const STRING_GENERIC_SYNC_ERROR = "There was an error syncing. Please try again. If all else fails, try signing out and signing back in.";
|
export declare const STRING_GENERIC_SYNC_ERROR = "There was an error syncing. Please try again. If all else fails, try signing out and signing back in.";
|
||||||
export declare function StringSyncException(data: any): string;
|
export declare function StringSyncException(data: any): string;
|
||||||
@@ -32,10 +32,6 @@ export declare const STRING_GENERATING_LOGIN_KEYS = "Generating Login Keys...";
|
|||||||
export declare const STRING_GENERATING_REGISTER_KEYS = "Generating Account Keys...";
|
export declare const STRING_GENERATING_REGISTER_KEYS = "Generating Account Keys...";
|
||||||
export declare const STRING_INVALID_IMPORT_FILE = "Unable to open file. Ensure it is a proper JSON file and try again.";
|
export declare const STRING_INVALID_IMPORT_FILE = "Unable to open file. Ensure it is a proper JSON file and try again.";
|
||||||
export declare function StringImportError(errorCount: number): string;
|
export declare function StringImportError(errorCount: number): string;
|
||||||
export declare const STRING_ENTER_ACCOUNT_PASSCODE = "Enter your application passcode to unlock the application";
|
|
||||||
export declare const STRING_ENTER_ACCOUNT_PASSWORD = "Enter your account password";
|
|
||||||
export declare const STRING_ENTER_PASSCODE_FOR_MIGRATION = "Your application passcode is required to perform an upgrade of your local data storage structure.";
|
|
||||||
export declare const STRING_ENTER_PASSCODE_FOR_LOGIN_REGISTER = "Enter your application passcode before signing in or registering";
|
|
||||||
export declare const STRING_STORAGE_UPDATE = "Storage Update";
|
export declare const STRING_STORAGE_UPDATE = "Storage Update";
|
||||||
export declare const STRING_AUTHENTICATION_REQUIRED = "Authentication Required";
|
export declare const STRING_AUTHENTICATION_REQUIRED = "Authentication Required";
|
||||||
export declare const STRING_UNSUPPORTED_BACKUP_FILE_VERSION = "This backup file was created using an unsupported version of the application and cannot be imported here. Please update your application and try again.";
|
export declare const STRING_UNSUPPORTED_BACKUP_FILE_VERSION = "This backup file was created using an unsupported version of the application and cannot be imported here. Please update your application and try again.";
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -10956,8 +10956,8 @@
|
|||||||
"from": "github:standardnotes/sncrypto#8794c88daa967eaae493cd5fdec7506d52b257ad"
|
"from": "github:standardnotes/sncrypto#8794c88daa967eaae493cd5fdec7506d52b257ad"
|
||||||
},
|
},
|
||||||
"snjs": {
|
"snjs": {
|
||||||
"version": "github:standardnotes/snjs#f922ede72a3e90984605048854dc20db8a88c790",
|
"version": "github:standardnotes/snjs#6a0e03eaca106ffda7bce7d87761a29fd8f8420d",
|
||||||
"from": "github:standardnotes/snjs#f922ede72a3e90984605048854dc20db8a88c790"
|
"from": "github:standardnotes/snjs#6a0e03eaca106ffda7bce7d87761a29fd8f8420d"
|
||||||
},
|
},
|
||||||
"sockjs": {
|
"sockjs": {
|
||||||
"version": "0.3.20",
|
"version": "0.3.20",
|
||||||
|
|||||||
@@ -68,6 +68,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sncrypto": "github:standardnotes/sncrypto#8794c88daa967eaae493cd5fdec7506d52b257ad",
|
"sncrypto": "github:standardnotes/sncrypto#8794c88daa967eaae493cd5fdec7506d52b257ad",
|
||||||
"snjs": "github:standardnotes/snjs#f922ede72a3e90984605048854dc20db8a88c790"
|
"snjs": "github:standardnotes/snjs#6a0e03eaca106ffda7bce7d87761a29fd8f8420d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user