feat: batch manager protection + react challenge modal + eslint fix

This commit is contained in:
Baptiste Grob
2021-01-22 11:37:58 +01:00
parent c6ff28b40e
commit 5d65364885
25 changed files with 1009 additions and 829 deletions

View File

@@ -1,6 +1,13 @@
{
"extends": ["eslint:recommended", "prettier"],
"root": true,
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:react-hooks/recommended"
],
"plugins": ["@typescript-eslint", "react"],
"parserOptions": {
"project": "./app/assets/javascripts/tsconfig.json"
},

View File

@@ -8,7 +8,7 @@ jobs:
tsc:
name: Check types
name: Check types & lint
runs-on: ubuntu-latest
@@ -22,6 +22,9 @@ jobs:
- name: Typescript
run: yarn tsc
- name: ESLint
run: yarn lint --quiet
deploy:
runs-on: ubuntu-latest

View File

@@ -7,12 +7,21 @@ on:
- master
jobs:
tsc:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
run: yarn install --pure-lockfile
- name: Typescript
run: yarn tsc
- name: ESLint
run: yarn lint --quiet

View File

@@ -8,7 +8,7 @@ jobs:
tsc:
name: Check types
name: Check types & lint
runs-on: ubuntu-latest
@@ -23,6 +23,9 @@ jobs:
- name: Typescript
run: yarn tsc
- name: ESLint
run: yarn lint --quiet
deploy:
runs-on: ubuntu-latest

View File

@@ -9,7 +9,7 @@ import {
} from '@standardnotes/snjs';
import { autorun, IAutorunOptions, IReactionPublic } from 'mobx';
import { render, FunctionComponent } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import { Dialog } from '@reach/dialog';
import { Alert } from '@reach/alert';
import {
@@ -19,7 +19,7 @@ import {
} from '@reach/alert-dialog';
function useAutorun(view: (r: IReactionPublic) => any, opts?: IAutorunOptions) {
useEffect(() => autorun(view, opts), []);
useEffect(() => autorun(view, opts), [view, opts]);
}
type Session = RemoteSession & {
@@ -57,16 +57,16 @@ function useSessions(
}
setRefreshing(false);
})();
}, [lastRefreshDate]);
}, [application, lastRefreshDate]);
function refresh() {
setLastRefreshDate(Date.now());
}
async function revokeSession(uuid: UuidString) {
const responsePromise = application.revokeSession(uuid);
const sessionsBeforeRevoke = sessions;
let sessionsBeforeRevoke = sessions;
const responsePromise = application.revokeSession(uuid);
const sessionsDuringRevoke = sessions.slice();
const toRemoveIndex = sessions.findIndex(
@@ -114,19 +114,23 @@ const SessionsModal: FunctionComponent<{
const closeRevokeSessionAlert = () => setRevokingSessionUuid('');
const cancelRevokeRef = useRef<HTMLButtonElement>();
const formatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: 'numeric',
});
const formatter = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: 'numeric',
}),
[]
);
return (
<>
<Dialog onDismiss={close}>
<div className="sk-modal-content sessions-modal">
<Dialog onDismiss={close} className="sessions-modal">
<div className="sk-modal-content">
<div class="sn-component">
<div class="sk-panel">
<div class="sk-panel-header">
@@ -250,7 +254,7 @@ const Sessions: FunctionComponent<{
}
};
class SessionsModalCtrl extends PureViewCtrl<{}, {}> {
class SessionsModalCtrl extends PureViewCtrl<unknown, unknown> {
/* @ngInject */
constructor(private $element: JQLite, $timeout: ng.ITimeoutService) {
super($timeout);
@@ -264,6 +268,7 @@ class SessionsModalCtrl extends PureViewCtrl<{}, {}> {
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function SessionsModalDirective() {
return {
controller: SessionsModalCtrl,

View File

@@ -24,9 +24,10 @@ export class BrowserBridge implements Bridge {
/** No-ops */
syncComponents() {}
onMajorDataChange() {}
onInitialDataLoad() {}
onSearch() {}
downloadBackup() {}
/* eslint-disable @typescript-eslint/no-empty-function */
syncComponents(): void {}
onMajorDataChange(): void {}
onInitialDataLoad(): void {}
onSearch(): void {}
downloadBackup(): void {}
}

View File

@@ -4,7 +4,7 @@ export enum KeyboardKey {
Backspace = "Backspace",
Up = "ArrowUp",
Down = "ArrowDown",
};
}
export enum KeyboardModifier {
Shift = "Shift",
@@ -12,12 +12,12 @@ export enum KeyboardModifier {
/** ⌘ key on Mac, ⊞ key on Windows */
Meta = "Meta",
Alt = "Alt",
};
}
enum KeyboardKeyEvent {
Down = "KeyEventDown",
Up = "KeyEventUp"
};
}
type KeyboardObserver = {
key?: KeyboardKey | string
@@ -39,10 +39,10 @@ export class KeyboardManager {
constructor() {
this.handleKeyDown = (event: KeyboardEvent) => {
this.notifyObserver(event, KeyboardKeyEvent.Down);
}
};
this.handleKeyUp = (event: KeyboardEvent) => {
this.notifyObserver(event, KeyboardKeyEvent.Up);
}
};
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
}

View File

@@ -9,9 +9,9 @@ import {
ComponentMutator,
Copy,
dictToArray
} from '@standardnotes/snjs';
import { PayloadContent } from '@standardnotes/snjs';
import { ComponentPermission } from '@standardnotes/snjs';
, PayloadContent , ComponentPermission } from '@standardnotes/snjs';
/** A class for handling installation of system extensions */
export class NativeExtManager extends ApplicationService {
@@ -82,7 +82,7 @@ export class NativeExtManager extends ApplicationService {
// Handle addition of SN|ExtensionRepo permission
const permissions = Copy(extensionsManager!.permissions) as ComponentPermission[];
const permission = permissions.find((p) => {
return p.name === ComponentAction.StreamItems
return p.name === ComponentAction.StreamItems;
});
if (permission && !permission.content_types!.includes(ContentType.ExtensionRepo)) {
permission.content_types!.push(ContentType.ExtensionRepo);
@@ -160,7 +160,7 @@ export class NativeExtManager extends ApplicationService {
// Handle addition of SN|ExtensionRepo permission
const permissions = Copy(batchManager!.permissions) as ComponentPermission[];
const permission = permissions.find((p) => {
return p.name === ComponentAction.StreamItems
return p.name === ComponentAction.StreamItems;
});
if (permission && !permission.content_types!.includes(ContentType.ExtensionRepo)) {
permission.content_types!.push(ContentType.ExtensionRepo);

View File

@@ -22,8 +22,6 @@ export class ThemeManager extends ApplicationService {
this.deactivateAllThemes();
} else if (event === ApplicationEvent.StorageReady) {
await this.activateCachedThemes();
if (!this.webApplication.getDesktopService().isDesktop) {
}
}
}
@@ -75,7 +73,7 @@ export class ThemeManager extends ApplicationService {
this.deactivateTheme(theme.uuid);
}
}
})
});
}
private clearAppThemeState() {

View File

@@ -1,5 +1,5 @@
declare module "*.pug" {
import { compileTemplate } from 'pug'
import { compileTemplate } from 'pug';
const content: compileTemplate;
export default content;
}

View File

@@ -10,6 +10,7 @@ import {
UuidString,
SyncOpStatus,
PrefKey,
Challenge,
} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { Editor } from '@/ui_models/editor';
@@ -289,10 +290,7 @@ export class AppState {
} else if (
note.archived &&
!this.selectedTag?.isArchiveTag &&
!this.application.getPreference(
PrefKey.NotesShowArchived,
false
)
!this.application.getPreference(PrefKey.NotesShowArchived, false)
) {
this.closeEditor(editor);
}

View File

@@ -7,7 +7,6 @@ import { PasswordWizardType, PasswordWizardScope } from '@/types';
import {
SNApplication,
platformFromString,
Challenge,
SNComponent,
PermissionDialog,
DeinitSource,
@@ -42,9 +41,9 @@ type WebServices = {
export class WebApplication extends SNApplication {
private scope?: ng.IScope
private scope?: angular.IScope
private webServices!: WebServices
private currentAuthenticationElement?: JQLite
private currentAuthenticationElement?: angular.IRootElementService
public editorGroup: EditorGroup
public componentGroup: ComponentGroup
@@ -52,8 +51,8 @@ export class WebApplication extends SNApplication {
constructor(
deviceInterface: WebDeviceInterface,
identifier: string,
private $compile: ng.ICompileService,
scope: ng.IScope,
private $compile: angular.ICompileService,
scope: angular.IScope,
defaultSyncServerHost: string,
private bridge: Bridge,
) {
@@ -98,10 +97,10 @@ export class WebApplication extends SNApplication {
* to complete before destroying the global application instance and all its services */
setTimeout(() => {
super.deinit(source);
}, 0)
}, 0);
}
onStart() {
onStart(): void {
super.onStart();
this.componentManager!.openModalComponent = this.openModalComponent;
this.componentManager!.presentPermissionsDialog = this.presentPermissionsDialog;
@@ -158,18 +157,6 @@ export class WebApplication extends SNApplication {
this.applicationElement.append(el);
}
promptForChallenge(challenge: Challenge) {
const scope: any = this.scope!.$new(true);
scope.challenge = challenge;
scope.application = this;
const el = this.$compile!(
"<challenge-modal " +
"class='sk-modal' application='application' challenge='challenge'>" +
"</challenge-modal>"
)(scope);
this.applicationElement.append(el);
}
authenticationInProgress() {
return this.currentAuthenticationElement != null;
}
@@ -214,7 +201,12 @@ export class WebApplication extends SNApplication {
this.applicationElement.append(el);
}
openModalComponent(component: SNComponent) {
async openModalComponent(component: SNComponent): Promise<void> {
if (component.package_info?.identifier === "org.standardnotes.batch-manager") {
if (!await this.authorizeBatchManagerAccess()) {
return;
}
}
const scope = this.scope!.$new(true) as Partial<ComponentModalScope>;
scope.componentUuid = component.uuid;
scope.application = this;

View File

@@ -1,13 +1,13 @@
import { SNComponent, ComponentArea, removeFromArray, addIfUnique } from '@standardnotes/snjs';
import { SNComponent, ComponentArea, removeFromArray, addIfUnique , UuidString } from '@standardnotes/snjs';
import { WebApplication } from './application';
import { UuidString } from '@standardnotes/snjs';
/** Areas that only allow a single component to be active */
const SingleComponentAreas = [
ComponentArea.Editor,
ComponentArea.NoteTags,
ComponentArea.TagsList
]
];
export class ComponentGroup {
@@ -20,7 +20,7 @@ export class ComponentGroup {
}
get componentManager() {
return this.application?.componentManager!;
return this.application.componentManager!;
}
public deinit() {
@@ -91,7 +91,7 @@ export class ComponentGroup {
callback();
return () => {
removeFromArray(this.changeObservers, callback);
}
};
}
private notifyObservers() {

View File

@@ -68,7 +68,7 @@ export class EditorGroup {
}
return () => {
removeFromArray(this.changeObservers, callback);
}
};
}
private notifyObservers() {

View File

@@ -77,10 +77,15 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
*/
this.state = Object.freeze(Object.assign({}, this.state, state));
resolve();
this.afterStateChange();
});
});
}
/** @override */
afterStateChange() {
}
/** @returns a promise that resolves after the UI has been updated. */
flushUI() {
return this.$timeout();

View File

@@ -27,3 +27,10 @@
sessions-modal(
application='self.application'
)
challenge-modal(
ng-repeat="challenge in self.state.challenges track by challenge.id"
class="sk-modal"
application="self.application"
challenge="challenge"
on-dismiss="self.removeChallenge(challenge)"
)

View File

@@ -3,7 +3,7 @@ import { WebDirective } from '@/types';
import { getPlatformString } from '@/utils';
import template from './application-view.pug';
import { AppStateEvent } from '@/ui_models/app_state';
import { ApplicationEvent } from '@standardnotes/snjs';
import { ApplicationEvent, Challenge } from '@standardnotes/snjs';
import {
PANEL_NAME_NOTES,
PANEL_NAME_TAGS
@@ -14,7 +14,12 @@ import {
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog } from '@/services/alertService';
class ApplicationViewCtrl extends PureViewCtrl {
class ApplicationViewCtrl extends PureViewCtrl<unknown, {
ready?: boolean,
needsUnlock?: boolean,
appClass: string,
challenges: Challenge[]
}> {
private $location?: ng.ILocationService
private $rootScope?: ng.IRootScopeService
public platformString: string
@@ -31,7 +36,7 @@ class ApplicationViewCtrl extends PureViewCtrl {
this.$location = $location;
this.$rootScope = $rootScope;
this.platformString = getPlatformString();
this.state = { appClass: '' };
this.state = { appClass: '', challenges: [] };
this.onDragDrop = this.onDragDrop.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.addDragDropHandlers();
@@ -40,11 +45,11 @@ class ApplicationViewCtrl extends PureViewCtrl {
deinit() {
this.$location = undefined;
this.$rootScope = undefined;
(this.application as any) = undefined;
(this.application as unknown) = undefined;
window.removeEventListener('dragover', this.onDragOver, true);
window.removeEventListener('drop', this.onDragDrop, true);
(this.onDragDrop as any) = undefined;
(this.onDragOver as any) = undefined;
(this.onDragDrop as unknown) = undefined;
(this.onDragOver as unknown) = undefined;
super.deinit();
}
@@ -59,12 +64,20 @@ class ApplicationViewCtrl extends PureViewCtrl {
);
await this.application!.prepareForLaunch({
receiveChallenge: async (challenge) => {
this.application!.promptForChallenge(challenge);
this.setState({
challenges: this.state.challenges.concat(challenge)
});
}
});
await this.application!.launch();
}
public removeChallenge(challenge: Challenge) {
this.setState({
challenges: this.state.challenges.filter(c => c.id !== challenge.id)
});
}
async onAppStart() {
super.onAppStart();
this.setState({

View File

@@ -1,71 +0,0 @@
.sk-modal-background(ng-click="ctrl.cancel()")
.challenge-modal.sk-modal-content(ng-if='ctrl.templateReady')
.sn-component
.sk-panel
.sk-panel-header
.sk-panel-header-title {{ctrl.challenge.modalTitle}}
.sk-panel-content
.sk-panel-section
.sk-p.sk-panel-row.centered.prompt
strong {{ctrl.challenge.heading}}
.sk-p.sk-panel-row.centered.subprompt(ng-if='ctrl.challenge.subheading')
| {{ctrl.challenge.subheading}}
.sk-panel-section
div(ng-repeat="prompt in ctrl.state.prompts track by prompt.id")
.sk-panel-row(
ng-if="prompt.validation != ctrl.protectionsSessionValidation"
)
input.sk-input.contrast(
ng-model="ctrl.state.values[prompt.id].value"
should-focus="$index == 0"
sn-autofocus="true"
sn-enter="ctrl.submit()" ,
ng-change="ctrl.onTextValueChange(prompt)"
ng-attr-type="{{prompt.secureTextEntry ? 'password' : 'text'}}",
ng-attr-placeholder="{{prompt.title}}"
)
.sk-horizontal-group(
ng-if="prompt.validation == ctrl.protectionsSessionValidation"
)
.sk-p.sk-bold Remember For
a.sk-a.info(
ng-repeat="option in ctrl.protectionsSessionDurations"
ng-class="{'boxed' : option.valueInSeconds == ctrl.state.values[prompt.id].value}"
ng-click="ctrl.onValueChange(prompt, option.valueInSeconds);"
)
| {{option.label}}
.sk-panel-row.centered
label.sk-label.danger(
ng-if="ctrl.state.values[prompt.id].invalid"
) Invalid authentication. Please try again.
.sk-panel-footer.extra-padding
.sk-button.info.big.block.bold(
ng-click="ctrl.submit()",
ng-class="{'info' : !ctrl.state.processing, 'neutral': ctrl.state.processing}"
ng-disabled="ctrl.state.processing"
)
.sk-label {{ctrl.state.processing ? 'Generating Keys...' : 'Submit'}}
.sk-panel-row(ng-if="ctrl.challenge.cancelable")
a.sk-panel-row.sk-a.info.centered(
ng-if="ctrl.challenge.cancelable"
ng-click="ctrl.cancel()"
) Cancel
.sk-panel-footer(ng-if="ctrl.state.showForgotPasscodeLink")
a.sk-panel-row.sk-a.info.centered(
ng-if="!ctrl.state.forgotPasscode"
ng-click="ctrl.onForgotPasscodeClick()"
) Forgot your passcode?
p.sk-panel-row.sk-p(ng-if="ctrl.state.forgotPasscode").
{{
ctrl.state.hasAccount
? "If you forgot your application passcode, your only option is to clear
your local data from this device and sign back in to your account."
: "If you forgot your application passcode, your only option is
to delete your data."
}}
a.sk-panel-row.sk-a.danger.centered(
ng-if="ctrl.state.forgotPasscode"
ng-click="ctrl.destroyLocalData()"
) Delete Local Data
.sk-panel-row

View File

@@ -1,216 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import template from './challenge-modal.pug';
import {
ChallengeValue,
removeFromArray,
Challenge,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
ProtectionSessionDurations,
} from '@standardnotes/snjs';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { WebDirective } from '@/types';
import { confirmDialog } from '@/services/alertService';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
type InputValue = {
prompt: ChallengePrompt;
value: string | number | boolean;
invalid: boolean;
};
type Values = Record<number, InputValue>;
type ChallengeModalState = {
prompts: ChallengePrompt[];
values: Partial<Values>;
processing: boolean;
forgotPasscode: boolean;
showForgotPasscodeLink: boolean;
processingPrompts: ChallengePrompt[];
hasAccount: boolean;
protectedNoteAccessDuration: number;
};
class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
application!: WebApplication;
challenge!: Challenge;
/** @template */
protectionsSessionDurations = ProtectionSessionDurations;
protectionsSessionValidation =
ChallengeValidation.ProtectionSessionDuration;
/* @ngInject */
constructor(private $element: JQLite, $timeout: ng.ITimeoutService) {
super($timeout);
}
getState() {
return this.state as ChallengeModalState;
}
$onInit() {
super.$onInit();
const values = {} as Values;
const prompts = this.challenge.prompts;
for (const prompt of prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
};
}
const showForgotPasscodeLink = [
ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration,
].includes(this.challenge.reason);
this.setState({
prompts,
values,
processing: false,
forgotPasscode: false,
showForgotPasscodeLink,
hasAccount: this.application.hasAccount(),
processingPrompts: [],
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
});
this.application.addChallengeObserver(this.challenge, {
onValidValue: (value) => {
this.getState().values[value.prompt.id]!.invalid = false;
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
},
onInvalidValue: (value) => {
this.getState().values[value.prompt.id]!.invalid = true;
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
this.setState({ processingPrompts: [], processing: false });
} else {
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
}
},
onComplete: () => {
this.dismiss();
},
onCancel: () => {
this.dismiss();
},
});
}
deinit() {
(this.application as any) = undefined;
(this.challenge as any) = undefined;
super.deinit();
}
reloadProcessingStatus() {
return this.setState({
processing: this.state.processingPrompts.length > 0,
});
}
async destroyLocalData() {
if (
await confirmDialog({
text: STRING_SIGN_OUT_CONFIRMATION,
confirmButtonStyle: 'danger',
})
) {
await this.application.signOut();
this.dismiss();
}
}
/** @template */
cancel() {
if (this.challenge.cancelable) {
this.application!.cancelChallenge(this.challenge);
}
}
onForgotPasscodeClick() {
this.setState({
forgotPasscode: true,
});
}
onTextValueChange(prompt: ChallengePrompt) {
const values = this.getState().values;
values[prompt.id]!.invalid = false;
this.setState({ values });
}
onValueChange(prompt: ChallengePrompt, value: number) {
const values = this.state.values;
values[prompt.id]!.invalid = false;
values[prompt.id]!.value = value;
}
validate() {
let failed = 0;
for (const prompt of this.state.prompts) {
const value = this.state.values[prompt.id]!;
if (typeof value.value === 'string' && value.value.length === 0) {
this.state.values[prompt.id]!.invalid = true;
failed++;
}
}
return failed === 0;
}
async submit() {
if (!this.validate()) {
return;
}
await this.setState({ processing: true });
const values: ChallengeValue[] = [];
for (const inputValue of Object.values(this.getState().values)) {
const rawValue = inputValue!.value;
const value = new ChallengeValue(inputValue!.prompt, rawValue);
values.push(value);
}
const processingPrompts = values.map((v) => v.prompt);
await this.setState({
processingPrompts: processingPrompts,
processing: processingPrompts.length > 0,
});
/**
* 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() {
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
}
export class ChallengeModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = ChallengeModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
challenge: '=',
application: '=',
};
}
}

View File

@@ -0,0 +1,404 @@
import { WebApplication } from '@/ui_models/application';
import { Dialog } from '@reach/dialog';
import {
ChallengeValue,
removeFromArray,
Challenge,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
ProtectionSessionDurations,
} from '@standardnotes/snjs';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { WebDirective } from '@/types';
import { confirmDialog } from '@/services/alertService';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
import { Ref, render } from 'preact';
import { useRef } from 'preact/hooks';
import ng from 'angular';
type InputValue = {
prompt: ChallengePrompt;
value: string | number | boolean;
invalid: boolean;
};
type Values = Record<number, InputValue>;
type ChallengeModalState = {
prompts: ChallengePrompt[];
values: Partial<Values>;
processing: boolean;
forgotPasscode: boolean;
showForgotPasscodeLink: boolean;
processingPrompts: ChallengePrompt[];
hasAccount: boolean;
protectedNoteAccessDuration: number;
};
class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
application!: WebApplication;
challenge!: Challenge;
onDismiss!: () => void;
/** @template */
protectionsSessionDurations = ProtectionSessionDurations;
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
/* @ngInject */
constructor(
private $element: ng.IRootElementService,
$timeout: ng.ITimeoutService
) {
super($timeout);
}
getState() {
return this.state as ChallengeModalState;
}
$onInit() {
super.$onInit();
const values = {} as Values;
const prompts = this.challenge.prompts;
for (const prompt of prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
};
}
const showForgotPasscodeLink = [
ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration,
].includes(this.challenge.reason);
this.setState({
prompts,
values,
processing: false,
forgotPasscode: false,
showForgotPasscodeLink,
hasAccount: this.application.hasAccount(),
processingPrompts: [],
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
});
this.application.addChallengeObserver(this.challenge, {
onValidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = false;
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
/** Trigger UI update */
this.afterStateChange();
},
onInvalidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = true;
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
this.setState({ processingPrompts: [], processing: false });
} else {
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
}
/** Trigger UI update */
this.afterStateChange();
},
onComplete: () => {
this.dismiss();
},
onCancel: () => {
this.dismiss();
},
});
}
deinit() {
(this.application as any) = undefined;
(this.challenge as any) = undefined;
super.deinit();
}
reloadProcessingStatus() {
return this.setState({
processing: this.state.processingPrompts.length > 0,
});
}
async destroyLocalData() {
if (
await confirmDialog({
text: STRING_SIGN_OUT_CONFIRMATION,
confirmButtonStyle: 'danger',
})
) {
await this.application.signOut();
this.dismiss();
}
}
/** @template */
cancel() {
if (this.challenge.cancelable) {
this.application!.cancelChallenge(this.challenge);
}
}
onForgotPasscodeClick() {
this.setState({
forgotPasscode: true,
});
}
onTextValueChange(prompt: ChallengePrompt) {
const values = this.getState().values;
values[prompt.id]!.invalid = false;
this.setState({ values });
}
onNumberValueChange(prompt: ChallengePrompt, value: number) {
const values = this.state.values;
values[prompt.id]!.invalid = false;
values[prompt.id]!.value = value;
this.setState({ values });
}
validate() {
let failed = 0;
for (const prompt of this.state.prompts) {
const value = this.state.values[prompt.id]!;
if (typeof value.value === 'string' && value.value.length === 0) {
this.state.values[prompt.id]!.invalid = true;
failed++;
}
}
return failed === 0;
}
async submit() {
if (!this.validate()) {
return;
}
await this.setState({ processing: true });
const values: ChallengeValue[] = [];
for (const inputValue of Object.values(this.getState().values)) {
const rawValue = inputValue!.value;
const value = new ChallengeValue(inputValue!.prompt, rawValue);
values.push(value);
}
const processingPrompts = values.map((v) => v.prompt);
await this.setState({
processingPrompts: processingPrompts,
processing: processingPrompts.length > 0,
});
/**
* 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);
}
afterStateChange() {
this.render();
}
dismiss() {
this.onDismiss();
}
$onDestroy() {
render(<></>, this.$element[0]);
}
private render() {
if (!this.state.prompts) return;
render(<ChallengeModalView ctrl={this} />, this.$element[0]);
}
}
export class ChallengeModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
// this.template = template;
this.controller = ChallengeModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
challenge: '=',
application: '=',
onDismiss: '&',
};
}
}
function ChallengeModalView({ ctrl }: { ctrl: ChallengeModalCtrl }) {
const initialFocusRef = useRef<HTMLInputElement>();
return (
<Dialog
initialFocusRef={initialFocusRef}
onDismiss={() => {
if (ctrl.challenge.cancelable) {
ctrl.dismiss();
}
}}
>
<div className="challenge-modal sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">
{ctrl.challenge.modalTitle}
</div>
</div>
<div className="sk-panel-content">
<div className="sk-panel-section">
<div className="sk-p sk-panel-row centered prompt">
<strong>{ctrl.challenge.heading}</strong>
</div>
{ctrl.challenge.subheading && (
<div className="sk-p sk-panel-row centered subprompt">
{ctrl.challenge.subheading}
</div>
)}
</div>
<div className="sk-panel-section">
{ChallengePrompts({ ctrl, initialFocusRef })}
</div>
</div>
<div className="sk-panel-footer extra-padding">
<div
className={
'sk-button big block bold ' +
(ctrl.state.processing ? 'neutral' : 'info')
}
disabled={ctrl.state.processing}
onClick={() => ctrl.submit()}
>
<div className="sk-label">
{ctrl.state.processing ? 'Generating Keys…' : 'Submit'}
</div>
</div>
{ctrl.challenge.cancelable && (
<>
<div className="sk-panel-row"></div>
<a
className="sk-panel-row sk-a info centered"
onClick={() => ctrl.cancel()}
>
Cancel
</a>
</>
)}
</div>
{ctrl.state.showForgotPasscodeLink && (
<div className="sk-panel-footer">
{ctrl.state.forgotPasscode ? (
<>
<p className="sk-panel-row sk-p">
{ctrl.state.hasAccount
? 'If you forgot your application passcode, your ' +
'only option is to clear your local data from this ' +
'device and sign back in to your account.'
: 'If you forgot your application passcode, your ' +
'only option is to delete your data.'}
</p>
<a
className="sk-panel-row sk-a danger centered"
onClick={() => {
ctrl.destroyLocalData();
}}
>
Delete Local Data
</a>
</>
) : (
<a
className="sk-panel-row sk-a info centered"
onClick={() => ctrl.onForgotPasscodeClick()}
>
Forgot your passcode?
</a>
)}
<div className="sk-panel-row"></div>
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}
function ChallengePrompts({
ctrl,
initialFocusRef,
}: {
ctrl: ChallengeModalCtrl;
initialFocusRef: Ref<HTMLInputElement>;
}) {
return ctrl.state.prompts.map((prompt, index) => (
<>
{/** ProtectionSessionDuration can't just be an input field */}
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div key={prompt.id} className="sk-panel-row">
<div className="sk-horizontal-group">
<div className="sk-p sk-bold">Remember For</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
'sk-a info ' +
(option.valueInSeconds === ctrl.state.values[prompt.id]!.value
? 'boxed'
: '')
}
onClick={(event) => {
event.preventDefault();
ctrl.onNumberValueChange(prompt, option.valueInSeconds);
}}
>
{option.label}
</a>
))}
</div>
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<input
className="sk-input contrast"
value={ctrl.state.values[prompt.id]!.value as string | number}
onChange={(event) => {
const value = (event.target as HTMLInputElement).value;
ctrl.state.values[prompt.id]!.value = value;
ctrl.onTextValueChange(prompt);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
ctrl.submit();
}
}}
ref={index === 0 ? initialFocusRef : undefined}
placeholder={prompt.title}
type={prompt.secureTextEntry ? 'password' : 'text'}
/>
</div>
)}
{ctrl.state.values[prompt.id]!.invalid && (
<div className="sk-panel-row centered">
<label className="sk-label danger">
Invalid authentication. Please try again.
</label>
</div>
)}
</>
));
}

View File

@@ -6,4 +6,4 @@ export { EditorView } from './editor/editor_view';
export { FooterView } from './footer/footer_view';
export { NotesView } from './notes/notes_view';
export { TagsView } from './tags/tags_view';
export { ChallengeModal } from './challenge_modal/challenge_modal'
export { ChallengeModal } from './challenge_modal/challenge_modal';

View File

@@ -20,11 +20,13 @@
}
[data-reach-dialog-content] {
width: auto;
padding: 0;
margin: 0;
position: relative;
overflow: unset;
flex-basis: 0;
min-width: 400px;
max-width: 600px;
}

View File

@@ -1,4 +1,7 @@
.sessions-modal {
min-width: 40vw;
width: auto;
h2, ul, p {
margin: 0;
padding: 0;

View File

@@ -16,7 +16,7 @@
"bundle:desktop:beta": "webpack --config webpack.prod.js --env.platform='desktop' --env.public_beta='true'",
"build": "bundle install && yarn install --pure-lockfile && bundle exec rails assets:precompile && yarn bundle",
"submodules": "git submodule update --init --force",
"lint": "eslint --fix app/assets/javascripts/**/*.js",
"lint": "eslint --fix app/assets/javascripts/**/*.{ts,tsx}",
"tsc": "tsc --project app/assets/javascripts/tsconfig.json"
},
"devDependencies": {
@@ -30,8 +30,8 @@
"@types/mocha": "^7.0.2",
"@types/pug": "^2.0.4",
"@types/react": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^3.10.1",
"@typescript-eslint/parser": "^3.10.1",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"angular": "^1.8.2",
"apply-loader": "^2.0.0",
"babel-eslint": "^10.1.0",
@@ -40,10 +40,10 @@
"connect": "^3.7.0",
"css-loader": "^3.4.2",
"dotenv": "^8.2.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-promise": "^4.2.1",
"eslint": "^7.18.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-standard": "^4.0.1",
"file-loader": "^5.1.0",
"html-webpack-plugin": "^4.3.0",
@@ -71,12 +71,9 @@
"@reach/alert-dialog": "^0.12.1",
"@reach/dialog": "^0.12.1",
"@standardnotes/sncrypto-web": "^1.2.9",
"@standardnotes/snjs": "^2.0.45",
"@standardnotes/snjs": "^2.0.47",
"babel-loader": "^8.2.2",
"mobx": "^6.0.4",
"preact": "^10.5.7"
},
"peerDependencies": {
"react": "^16.8.0"
"preact": "^10.5.11"
}
}

928
yarn.lock

File diff suppressed because it is too large Load Diff