feat: handle unprotected session expiration (#747)

* feat: hide note contents if the protection expires when the protected note is open and wasn't edited for a while

* feat: handle session expiration for opened protected note for both plain advanced editors

* fix: if after canceling  session expiry modal only one unprotected note stays selected, show its contents in the editor

* refactor: handle session expiration for opened protected note (move the logic to web client)

* feat: handle the case of selecting "Don't remember" option in session expiry dialog

* test (WIP): add unit tests for protecting opened note after the session has expired

* test: add remaining unit tests

* refactor: move the opened note protection logic to "editor_view"

* refactor: reviewer comments
- don't rely on user signed-in/out status to require authentication for protected note
- remove unnecessary async/awaits
- better wording on ui

* refactor: reviewer's comments:
 - use snjs method to check if "Don't remember" option is selected in authentication modal
 - move the constant to snjs
 - fix eslint error

* refactor: avoid `any` type for `appEvent` payload

* test: add unit tests

* chore: update function name

* refactor: use simpler protection session event types

* refactor: protected access terminology

* refactor: start counting idle timer after every edit (instead of counting by interval in spite of edits)

* test: unit tests

* style: don't give extra brightness to the "View Note"/"Authenticate" button on hover/focus

* chore: bump snjs version

Co-authored-by: Mo Bitar <me@bitar.io>
This commit is contained in:
Vardan Hakobyan
2021-12-14 19:14:01 +04:00
committed by GitHub
parent f494b106b9
commit 8db549f6f6
26 changed files with 418 additions and 755 deletions

View File

@@ -11,7 +11,7 @@
"parserOptions": {
"project": "./app/assets/javascripts/tsconfig.json"
},
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js"],
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js", "__mocks__"],
"rules": {
"standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals
"no-throw-literal": 0,

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ yarn-error.log
package-lock.json
codeqldb
coverage

View File

@@ -0,0 +1,11 @@
const {
ApplicationEvent,
ProtectionSessionDurations,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} = require('@standardnotes/snjs');
module.exports = {
ApplicationEvent: ApplicationEvent,
ProtectionSessionDurations: ProtectionSessionDurations,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
};

View File

@@ -1,180 +0,0 @@
import { isDesktopApplication } from '@/utils';
import { alertDialog } from '@Services/alertService';
import {
STRING_IMPORT_SUCCESS,
STRING_INVALID_IMPORT_FILE,
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
StringImportError
} from '@/strings';
import { BackupFile } from '@standardnotes/snjs';
import { useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
type Props = {
application: WebApplication;
appState: AppState;
}
const DataBackup = observer(({
application,
appState
}: Props) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu;
const downloadDataArchive = () => {
application.getArchiveService().downloadBackup(isBackupEncrypted);
};
const readFile = async (file: File): Promise<any> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target!.result as string);
resolve(data);
} catch (e) {
application.alertService.alert(STRING_INVALID_IMPORT_FILE);
}
};
reader.readAsText(file);
});
};
const performImport = async (data: BackupFile) => {
setIsImportDataLoading(true);
const result = await application.importData(data);
setIsImportDataLoading(false);
if (!result) {
return;
}
let statusText = STRING_IMPORT_SUCCESS;
if ('error' in result) {
statusText = result.error;
} else if (result.errorCount) {
statusText = StringImportError(result.errorCount);
}
void alertDialog({
text: statusText
});
};
const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
const { files } = (event.target as HTMLInputElement);
if (!files) {
return;
}
const file = files[0];
const data = await readFile(file);
if (!data) {
return;
}
const version = data.version || data.keyParams?.version || data.auth_params?.version;
if (!version) {
await performImport(data);
return;
}
if (
application.protocolService.supportedVersions().includes(version)
) {
await performImport(data);
} else {
setIsImportDataLoading(false);
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION });
}
};
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
if (event instanceof KeyboardEvent) {
const { code } = event;
// Process only when "Enter" or "Space" keys are pressed
if (code !== 'Enter' && code !== 'Space') {
return;
}
// Don't proceed the event's default action
// (like scrolling in case the "space" key is pressed)
event.preventDefault();
}
(fileInputRef.current as HTMLInputElement).click();
};
return (
<>
{isImportDataLoading ? (
<div className="sk-spinner small info" />
) : (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Data Backups</div>
<div className="sk-p">Download a backup of all your data.</div>
{isEncryptionEnabled && (
<form className="sk-panel-form sk-panel-row">
<div className="sk-input-group">
<label className="sk-horizontal-group tight">
<input
type="radio"
onChange={() => setIsBackupEncrypted(true)}
checked={isBackupEncrypted}
/>
<p className="sk-p">Encrypted</p>
</label>
<label className="sk-horizontal-group tight">
<input
type="radio"
onChange={() => setIsBackupEncrypted(false)}
checked={!isBackupEncrypted}
/>
<p className="sk-p">Decrypted</p>
</label>
</div>
</form>
)}
<div className="sk-panel-row" />
<div className="flex">
<button className="sn-button small info" onClick={downloadDataArchive}>Download Backup</button>
<button
type="button"
className="sn-button small flex items-center info ml-2"
tabIndex={0}
onClick={handleImportFile}
onKeyDown={handleImportFile}
>
<input
type="file"
ref={fileInputRef}
onChange={importFileSelected}
className="hidden"
/>
Import Backup
</button>
</div>
{isDesktopApplication() && (
<p className="mt-5">
Backups are automatically created on desktop and can be managed
via the "Backups" top-level menu.
</p>
)}
<div className="sk-panel-row" />
</div>
)}
</>
);
});
export default DataBackup;

View File

@@ -1,33 +0,0 @@
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
type Props = {
appState: AppState;
}
const Encryption = observer(({ appState }: Props) => {
const { isEncryptionEnabled, encryptionStatusString, notesAndTagsCount } = appState.accountMenu;
const getEncryptionStatusForNotes = () => {
const length = notesAndTagsCount;
return `${length}/${length} notes and tags encrypted`;
};
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">
Encryption
</div>
{isEncryptionEnabled && (
<div className="sk-panel-section-subtitle info">
{getEncryptionStatusForNotes()}
</div>
)}
<p className="sk-p">
{encryptionStatusString}
</p>
</div>
);
});
export default Encryption;

View File

@@ -1,80 +0,0 @@
import { useState } from 'preact/hooks';
import { storage, StorageKey } from '@Services/localStorage';
import { disableErrorReporting, enableErrorReporting, errorReportingId } from '@Services/errorReporting';
import { alertDialog } from '@Services/alertService';
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
type Props = {
appState: AppState;
}
const ErrorReporting = observer(({ appState }: Props) => {
const [isErrorReportingEnabled] = useState(() => storage.get(StorageKey.DisableErrorReporting) === false);
const [errorReportingIdValue] = useState(() => errorReportingId());
const toggleErrorReportingEnabled = () => {
if (isErrorReportingEnabled) {
disableErrorReporting();
} else {
enableErrorReporting();
}
if (!appState.sync.inProgress) {
window.location.reload();
}
};
const openErrorReportingDialog = () => {
alertDialog({
title: 'Data sent during automatic error reporting',
text: `
We use <a target="_blank" rel="noreferrer" href="https://www.bugsnag.com/">Bugsnag</a>
to automatically report errors that occur while the app is running. See
<a target="_blank" rel="noreferrer" href="https://docs.bugsnag.com/platforms/javascript/#sending-diagnostic-data">
this article, paragraph 'Browser' under 'Sending diagnostic data',
</a>
to see what data is included in error reports.
<br><br>
Error reports never include IP addresses and are fully
anonymized. We use error reports to be alerted when something in our
code is causing unexpected errors and crashes in your application
experience.
`
});
};
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Error Reporting</div>
<div className="sk-panel-section-subtitle info">
Automatic error reporting is {isErrorReportingEnabled ? 'enabled' : 'disabled'}
</div>
<p className="sk-p">
Help us improve Standard Notes by automatically submitting
anonymized error reports.
</p>
{errorReportingIdValue && (
<>
<p className="sk-p selectable">
Your random identifier is <span className="font-bold">{errorReportingIdValue}</span>
</p>
<p className="sk-p">
Disabling error reporting will remove that identifier from your
local storage, and a new identifier will be created should you
decide to enable error reporting again in the future.
</p>
</>
)}
<div className="sk-panel-row">
<button className="sn-button small info" onClick={toggleErrorReportingEnabled}>
{isErrorReportingEnabled ? 'Disable' : 'Enable'} Error Reporting
</button>
</div>
<div className="sk-panel-row">
<a className="sk-a" onClick={openErrorReportingDialog}>What data is being sent?</a>
</div>
</div>
);
});
export default ErrorReporting;

View File

@@ -1,272 +0,0 @@
import {
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, STRING_E2E_ENABLED, STRING_ENC_NOT_ENABLED, STRING_LOCAL_ENC_ENABLED,
STRING_NON_MATCHING_PASSCODES,
StringUtils,
Strings
} from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { preventRefreshing } from '@/utils';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { alertDialog } from '@Services/alertService';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
appState: AppState;
};
const PasscodeLock = observer(({
application,
appState,
}: Props) => {
const keyStorageInfo = StringUtils.keyStorageInfo(application);
const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions();
const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu;
const passcodeInputRef = useRef<HTMLInputElement>(null);
const [passcode, setPasscode] = useState<string | undefined>(undefined);
const [passcodeConfirmation, setPasscodeConfirmation] = useState<string | undefined>(undefined);
const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState<unknown>(null);
const [isPasscodeFocused, setIsPasscodeFocused] = useState(false);
const [showPasscodeForm, setShowPasscodeForm] = useState(false);
const [canAddPasscode, setCanAddPasscode] = useState(!application.isEphemeralSession());
const [hasPasscode, setHasPasscode] = useState(application.hasPasscode());
const handleAddPassCode = () => {
setShowPasscodeForm(true);
setIsPasscodeFocused(true);
};
const changePasscodePressed = () => {
handleAddPassCode();
};
const reloadAutoLockInterval = useCallback(async () => {
const interval = await application.getAutolockService().getAutoLockInterval();
setSelectedAutoLockInterval(interval);
}, [application]);
const refreshEncryptionStatus = useCallback(() => {
const hasUser = application.hasAccount();
const hasPasscode = application.hasPasscode();
setHasPasscode(hasPasscode);
const encryptionEnabled = hasUser || hasPasscode;
const encryptionStatusString = hasUser
? STRING_E2E_ENABLED
: hasPasscode
? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED;
setEncryptionStatusString(encryptionStatusString);
setIsEncryptionEnabled(encryptionEnabled);
setIsBackupEncrypted(encryptionEnabled);
}, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled]);
const selectAutoLockInterval = async (interval: number) => {
if (!(await application.authorizeAutolockIntervalChange())) {
return;
}
await application.getAutolockService().setAutoLockInterval(interval);
reloadAutoLockInterval();
};
const removePasscodePressed = async () => {
await preventRefreshing(
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
async () => {
if (await application.removePasscode()) {
await application
.getAutolockService()
.deleteAutolockPreference();
await reloadAutoLockInterval();
refreshEncryptionStatus();
}
}
);
};
const handlePasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPasscode(value);
};
const handleConfirmPasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPasscodeConfirmation(value);
};
const submitPasscodeForm = async (event: TargetedEvent<HTMLFormElement> | TargetedMouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (!passcode || passcode.length === 0) {
await alertDialog({
text: Strings.enterPasscode,
});
}
if (passcode !== passcodeConfirmation) {
await alertDialog({
text: STRING_NON_MATCHING_PASSCODES
});
setIsPasscodeFocused(true);
return;
}
await preventRefreshing(
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
async () => {
const successful = application.hasPasscode()
? await application.changePasscode(passcode as string)
: await application.addPasscode(passcode as string);
if (!successful) {
setIsPasscodeFocused(true);
}
}
);
setPasscode(undefined);
setPasscodeConfirmation(undefined);
setShowPasscodeForm(false);
refreshEncryptionStatus();
};
useEffect(() => {
refreshEncryptionStatus();
}, [refreshEncryptionStatus]);
// `reloadAutoLockInterval` gets interval asynchronously, therefore we call `useEffect` to set initial
// value of `selectedAutoLockInterval`
useEffect(() => {
reloadAutoLockInterval();
}, [reloadAutoLockInterval]);
useEffect(() => {
if (isPasscodeFocused) {
passcodeInputRef.current!.focus();
setIsPasscodeFocused(false);
}
}, [isPasscodeFocused]);
// Add the required event observers
useEffect(() => {
const removeKeyStatusChangedObserver = application.addEventObserver(
async () => {
setCanAddPasscode(!application.isEphemeralSession());
setHasPasscode(application.hasPasscode());
setShowPasscodeForm(false);
},
ApplicationEvent.KeyStatusChanged
);
return () => {
removeKeyStatusChangedObserver();
};
}, [application]);
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Passcode Lock</div>
{!hasPasscode && (
<div>
{canAddPasscode && (
<>
{!showPasscodeForm && (
<div className="sk-panel-row">
<button className="sn-button small info" onClick={handleAddPassCode}>
Add Passcode
</button>
</div>
)}
<p className="sk-p">
Add a passcode to lock the application and
encrypt on-device key storage.
</p>
{keyStorageInfo && (
<p>{keyStorageInfo}</p>
)}
</>
)}
{!canAddPasscode && (
<p className="sk-p">
Adding a passcode is not supported in temporary sessions. Please sign
out, then sign back in with the "Stay signed in" option checked.
</p>
)}
</div>
)}
{showPasscodeForm && (
<form className="sk-panel-form" onSubmit={submitPasscodeForm}>
<div className="sk-panel-row" />
<input
className="sk-input contrast"
type="password"
ref={passcodeInputRef}
value={passcode}
onChange={handlePasscodeChange}
placeholder="Passcode"
/>
<input
className="sk-input contrast"
type="password"
value={passcodeConfirmation}
onChange={handleConfirmPasscodeChange}
placeholder="Confirm Passcode"
/>
<button className="sn-button small info mt-2" onClick={submitPasscodeForm}>
Set Passcode
</button>
<button className="sn-button small outlined ml-2" onClick={() => setShowPasscodeForm(false)}>
Cancel
</button>
</form>
)}
{hasPasscode && !showPasscodeForm && (
<>
<div className="sk-panel-section-subtitle info">Passcode lock is enabled</div>
<div className="sk-notification contrast">
<div className="sk-notification-title">Options</div>
<div className="sk-notification-text">
<div className="sk-panel-row">
<div className="sk-horizontal-group">
<div className="sk-h4 sk-bold">Autolock</div>
{passcodeAutoLockOptions.map(option => {
return (
<a
className={`sk-a info ${option.value === selectedAutoLockInterval ? 'boxed' : ''}`}
onClick={() => selectAutoLockInterval(option.value)}>
{option.label}
</a>
);
})}
</div>
</div>
<div className="sk-p">The autolock timer begins when the window or tab loses focus.</div>
<div className="sk-panel-row" />
<a className="sk-a info sk-panel-row condensed" onClick={changePasscodePressed}>
Change Passcode
</a>
<a className="sk-a danger sk-panel-row condensed" onClick={removePasscodePressed}>
Remove Passcode
</a>
</div>
</div>
</>
)}
</div>
);
});
export default PasscodeLock;

View File

@@ -1,100 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { useEffect } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import { isSameDay } from '@/utils';
type Props = {
application: WebApplication;
};
const Protections: FunctionalComponent<Props> = ({ application }) => {
const enableProtections = () => {
application.clearProtectionSession();
};
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources());
const getProtectionsDisabledUntil = useCallback((): string | null => {
const protectionExpiry = application.getProtectionSessionExpiryDate();
const now = new Date();
if (protectionExpiry > now) {
let f: Intl.DateTimeFormat;
if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric'
});
} else {
f = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric'
});
}
return f.format(protectionExpiry);
}
return null;
}, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
useEffect(() => {
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.ProtectionSessionExpiryDateChanged
);
const removeKeyStatusChangedObserver = application.addEventObserver(
async () => {
setHasProtections(application.hasProtectionSources());
},
ApplicationEvent.KeyStatusChanged
);
return () => {
removeProtectionSessionExpiryDateChangedObserver();
removeKeyStatusChangedObserver();
};
}, [application, getProtectionsDisabledUntil]);
if (!hasProtections) {
return null;
}
return (
<div className="sk-panel-section">
<div className="sk-panel-section-title">Protections</div>
{protectionsDisabledUntil && (
<div className="sk-panel-section-subtitle info">
Protections are disabled until {protectionsDisabledUntil}
</div>
)}
{!protectionsDisabledUntil && (
<div className="sk-panel-section-subtitle info">
Protections are enabled
</div>
)}
<p className="sk-p">
Actions like viewing protected notes, exporting decrypted backups,
or revoking an active session, require additional authentication
like entering your account password or application passcode.
</p>
{protectionsDisabledUntil && (
<div className="sk-panel-row">
<button className="sn-button small info" onClick={enableProtections}>
Enable protections
</button>
</div>
)}
</div>
);
};
export default Protections;

View File

@@ -1,17 +1,27 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
type Props = { appState: AppState; onViewNote: () => void };
type Props = {
appState: AppState;
onViewNote: () => void;
requireAuthenticationForProtectedNote: boolean;
};
function NoProtectionsNoteWarning({
appState,
onViewNote,
requireAuthenticationForProtectedNote,
}: Props) {
const instructionText = requireAuthenticationForProtectedNote
? 'Authenticate to view this note.'
: 'Add a passcode or create an account to require authentication to view this note.';
function NoProtectionsNoteWarning({ appState, onViewNote }: Props) {
return (
<div className="flex flex-col items-center justify-center text-center max-w-md">
<h1 className="text-2xl m-0 w-full">This note is protected</h1>
<p className="text-lg mt-2 w-full">
Add a passcode or create an account to require authentication to view
this note.
</p>
<p className="text-lg mt-2 w-full">{instructionText}</p>
<div className="mt-4 flex gap-3">
{!requireAuthenticationForProtectedNote && (
<button
className="sn-button small info"
onClick={() => {
@@ -20,8 +30,12 @@ function NoProtectionsNoteWarning({ appState, onViewNote }: Props) {
>
Open account menu
</button>
<button className="sn-button small outlined" onClick={onViewNote}>
View note
)}
<button
className="sn-button small outlined normal-focus-brightness"
onClick={onViewNote}
>
{requireAuthenticationForProtectedNote ? 'Authenticate' : 'View Note'}
</button>
</div>
</div>
@@ -32,5 +46,6 @@ export const NoProtectionsdNoteWarningDirective = toDirective<Props>(
NoProtectionsNoteWarning,
{
onViewNote: '&',
requireAuthenticationForProtectedNote: '=',
}
);

View File

@@ -1,7 +1,7 @@
import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon';
import { toDirective, useCloseOnBlur } from './utils';
import { useRef, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import VisuallyHidden from '@reach/visually-hidden';
import {
@@ -11,7 +11,6 @@ import {
} from '@reach/disclosure';
import { Switch } from './Switch';
import { observer } from 'mobx-react-lite';
import { useEffect } from 'react';
type Props = {
appState: AppState;

View File

@@ -1,7 +1,6 @@
import { FunctionComponent, h, render } from 'preact';
import { unmountComponentAtNode } from 'preact/compat';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { useEffect } from 'react';
import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
/**
* @returns a callback that will close a dropdown if none of its children has

View File

@@ -1,10 +1,13 @@
const pathsToModuleNameMapper = require('ts-jest/utils').pathsToModuleNameMapper;
const pathsToModuleNameMapper =
require('ts-jest/utils').pathsToModuleNameMapper;
const tsConfig = require('./tsconfig.json');
const pathsFromTsconfig = tsConfig.compilerOptions.paths;
module.exports = {
restoreMocks: true,
clearMocks: true,
resetMocks: true,
moduleNameMapper: {
...pathsToModuleNameMapper(pathsFromTsconfig, {
prefix: '<rootDir>',
@@ -14,7 +17,6 @@ module.exports = {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
globals: {
window: {},
__VERSION__: '1.0.0',
__DESKTOP__: false,
__WEB__: true,

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/Button';
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from '@node_modules/preact/hooks';
import { observer } from '@node_modules/mobx-react-lite';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact';

View File

@@ -4,7 +4,12 @@ import { useCallback, useState } from 'preact/hooks';
import { useEffect } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs';
import { isSameDay } from '@/utils';
import { PreferencesGroup, PreferencesSegment, Title, Text } from '@/preferences/components';
import {
PreferencesGroup,
PreferencesSegment,
Title,
Text,
} from '@/preferences/components';
import { Button } from '@/components/Button';
type Props = {
@@ -16,7 +21,9 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
application.clearProtectionSession();
};
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources());
const [hasProtections, setHasProtections] = useState(() =>
application.hasProtectionSources()
);
const getProtectionsDisabledUntil = useCallback((): string | null => {
const protectionExpiry = application.getProtectionSessionExpiryDate();
@@ -26,7 +33,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric'
minute: 'numeric',
});
} else {
f = new Intl.DateTimeFormat(undefined, {
@@ -34,7 +41,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric'
minute: 'numeric',
});
}
@@ -43,14 +50,23 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
return null;
}, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(
getProtectionsDisabledUntil()
);
useEffect(() => {
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
const removeUnprotectedSessionBeginObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.ProtectionSessionExpiryDateChanged
ApplicationEvent.UnprotectedSessionBegan
);
const removeUnprotectedSessionEndObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionExpired
);
const removeKeyStatusChangedObserver = application.addEventObserver(
@@ -61,7 +77,8 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
);
return () => {
removeProtectionSessionExpiryDateChangedObserver();
removeUnprotectedSessionBeginObserver();
removeUnprotectedSessionEndObserver();
removeKeyStatusChangedObserver();
};
}, [application, getProtectionsDisabledUntil]);
@@ -74,18 +91,27 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
<PreferencesGroup>
<PreferencesSegment>
<Title>Protections</Title>
{protectionsDisabledUntil
? <Text className="info">Protections are disabled until {protectionsDisabledUntil}.</Text>
: <Text className="info">Protections are enabled.</Text>
}
<Text className="mt-2">
Actions like viewing protected notes, exporting decrypted backups,
or revoking an active session, require additional authentication
like entering your account password or application passcode.
{protectionsDisabledUntil ? (
<Text className="info">
Unprotected access expires at {protectionsDisabledUntil}.
</Text>
{protectionsDisabledUntil &&
<Button className="mt-3" type="primary" label="Enable Protections" onClick={enableProtections} />
}
) : (
<Text className="info">Protections are enabled.</Text>
)}
<Text className="mt-2">
Actions like viewing or searching protected notes, exporting decrypted
backups, or revoking an active session require additional
authentication such as entering your account password or application
passcode.
</Text>
{protectionsDisabledUntil && (
<Button
className="mt-3"
type="primary"
label="End Unprotected Access"
onClick={enableProtections}
/>
)}
</PreferencesSegment>
</PreferencesGroup>
);

View File

@@ -5,7 +5,8 @@ import {
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import { useState, useRef, useEffect, MouseEventHandler } from 'react';
import { MouseEventHandler } from 'react';
import { useState, useRef, useEffect } from 'preact/hooks';
const DisclosureIconButton: FunctionComponent<{
className?: string;

View File

@@ -1,6 +1,6 @@
import { ApplicationEvent } from "@standardnotes/snjs";
import { makeObservable, observable, action, runInAction } from "mobx";
import { WebApplication } from "../application";
import { ApplicationEvent } from '@standardnotes/snjs';
import { makeObservable, observable, action, runInAction } from 'mobx';
import { WebApplication } from '../application';
export class SearchOptionsState {
includeProtectedContents = false;
@@ -25,7 +25,10 @@ export class SearchOptionsState {
appObservers.push(
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents();
}, ApplicationEvent.ProtectionSessionExpiryDateChanged)
}, ApplicationEvent.UnprotectedSessionBegan),
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents();
}, ApplicationEvent.UnprotectedSessionExpired)
);
}
@@ -38,21 +41,17 @@ export class SearchOptionsState {
};
refreshIncludeProtectedContents = (): void => {
if (
this.includeProtectedContents &&
this.application.areProtectionsEnabled()
) {
this.includeProtectedContents = false;
}
this.includeProtectedContents =
this.application.hasUnprotectedAccessSession();
};
toggleIncludeProtectedContents = async (): Promise<void> => {
if (this.includeProtectedContents) {
this.includeProtectedContents = false;
} else {
const authorized = await this.application.authorizeSearchingProtectedNotesText();
await this.application.authorizeSearchingProtectedNotesText();
runInAction(() => {
this.includeProtectedContents = authorized;
this.refreshIncludeProtectedContents();
});
}
};

View File

@@ -129,8 +129,8 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
if (this.application!.isLaunched()) {
this.onAppLaunch();
}
this.unsubApp = this.application!.addEventObserver(async (eventName) => {
this.onAppEvent(eventName);
this.unsubApp = this.application!.addEventObserver(async (eventName, data: any) => {
this.onAppEvent(eventName, data);
if (eventName === ApplicationEvent.Started) {
await this.onAppStart();
} else if (eventName === ApplicationEvent.Launched) {
@@ -147,7 +147,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
});
}
onAppEvent(eventName: ApplicationEvent) {
onAppEvent(eventName: ApplicationEvent, data?: any) {
/** Optional override */
}

View File

@@ -19,9 +19,9 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
needsUnlock?: boolean,
appClass: string,
}> {
public platformString: string
private notesCollapsed = false
private tagsCollapsed = false
public platformString: string;
private notesCollapsed = false;
private tagsCollapsed = false;
/**
* To prevent stale state reads (setState is async),
* challenges is a mutable array
@@ -108,14 +108,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
if (eventName === ApplicationEvent.LocalDatabaseReadError) {
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.'
});
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.'
});
break;
}
}

View File

@@ -352,8 +352,8 @@ function ChallengePrompts({
{/** 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>
<div className="sk-horizontal-group mt-3">
<div className="sk-p sk-bold">Allow protected access for</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
@@ -374,10 +374,13 @@ function ChallengePrompts({
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<form className="w-full" onSubmit={(event) => {
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
ctrl.submit();
}}>
}}
>
<input
className="sk-input contrast"
value={ctrl.state.values[prompt.id]!.value as string | number}

View File

@@ -1,4 +1,4 @@
export const PANEL_NAME_NOTES = 'notes';
export const PANEL_NAME_TAGS = 'tags';
// eslint-disable-next-line no-useless-escape
export const EMAIL_REGEX = /^([a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
export const EMAIL_REGEX =
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;

View File

@@ -2,6 +2,7 @@
protected-note-panel.h-full.flex.justify-center.items-center(
ng-if='self.state.showProtectedWarning'
app-state='self.appState'
require-authentication-for-protected-note='self.requireAuthenticationForProtectedNote'
on-view-note='self.dismissProtectedWarning()'
)
.flex-grow.flex.flex-col(

View File

@@ -0,0 +1,196 @@
/**
* @jest-environment jsdom
*/
import { EditorViewCtrl } from '@Views/editor/editor_view';
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs/';
describe('editor-view', () => {
let ctrl: EditorViewCtrl;
let setShowProtectedWarningSpy: jest.SpyInstance;
beforeEach(() => {
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
ctrl = new EditorViewCtrl($timeout);
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedWarning');
Object.defineProperties(ctrl, {
application: {
value: {
getAppState: () => {
return {
notes: {
setShowProtectedWarning: jest.fn(),
},
};
},
hasProtectionSources: () => true,
authorizeNoteAccess: jest.fn(),
},
},
removeComponentsObserver: {
value: jest.fn(),
writable: true,
},
removeTrashKeyObserver: {
value: jest.fn(),
writable: true,
},
unregisterComponent: {
value: jest.fn(),
writable: true,
},
editor: {
value: {
clearNoteChangeListener: jest.fn(),
},
},
});
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
afterEach(() => {
ctrl.deinit();
});
describe('note is protected', () => {
beforeEach(() => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: true,
},
});
});
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
jest
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
.mockImplementation(
() =>
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction +
5
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
const secondsElapsedSinceLastEdit =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
const secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
const secondsElapsedSinceLastModification = 3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(
Date.now() - secondsElapsedSinceLastModification * 1000
),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
let secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastModification;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
// A new modification has just happened
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(),
configurable: true,
});
secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
});
describe('note is unprotected', () => {
it('should not call any hiding logic', async () => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: false,
},
});
const hideProtectedNoteIfInactiveSpy = jest.spyOn(
ctrl,
'hideProtectedNoteIfInactive'
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled();
});
});
describe('dismissProtectedWarning', () => {
describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
it('should not reveal note contents if the authorization has not been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
});
});
describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => {
jest
.spyOn(ctrl.application, 'hasProtectionSources')
.mockImplementation(() => false);
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
});
});
});

View File

@@ -16,6 +16,7 @@ import {
PrefKey,
ComponentMutator,
PayloadSource,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs';
import { isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
@@ -89,7 +90,7 @@ function sortAlphabetically(array: SNComponent[]): SNComponent[] {
);
}
class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
export class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
/** Passed through template */
readonly application!: WebApplication;
readonly editor!: Editor;
@@ -108,6 +109,8 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
private removeTabObserver?: any;
private removeComponentsObserver!: () => void;
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
public requireAuthenticationForProtectedNote = false;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
@@ -124,14 +127,15 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.setScrollPosition = this.setScrollPosition.bind(this);
this.resetScrollPosition = this.resetScrollPosition.bind(this);
this.onEditorLoad = () => {
this.application!.getDesktopService().redoSearch();
this.application.getDesktopService().redoSearch();
};
}
deinit() {
this.clearNoteProtectionInactivityTimer();
this.editor.clearNoteChangeListener();
this.removeComponentsObserver();
(this.removeComponentsObserver as any) = undefined;
(this.removeComponentsObserver as unknown) = undefined;
this.removeTrashKeyObserver();
this.removeTrashKeyObserver = undefined;
this.removeTabObserver && this.removeTabObserver();
@@ -143,8 +147,8 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.unregisterComponent = undefined;
this.saveTimeout = undefined;
this.statusTimeout = undefined;
(this.onPanelResizeFinish as any) = undefined;
(this.editorMenuOnSelect as any) = undefined;
(this.onPanelResizeFinish as unknown) = undefined;
(this.editorMenuOnSelect as unknown) = undefined;
super.deinit();
}
@@ -229,7 +233,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
/** @override */
onAppEvent(eventName: ApplicationEvent) {
async onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences();
@@ -262,14 +266,64 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
desc: 'Changes not saved',
});
break;
case ApplicationEvent.UnprotectedSessionBegan: {
this.setShowProtectedWarning(false);
break;
}
case ApplicationEvent.UnprotectedSessionExpired: {
if (this.note.protected) {
this.hideProtectedNoteIfInactive();
}
break;
}
}
}
getSecondsElapsedSinceLastEdit(): number {
return (Date.now() - this.note.userModifiedDate.getTime()) / 1000;
}
hideProtectedNoteIfInactive(): void {
const secondsElapsedSinceLastEdit = this.getSecondsElapsedSinceLastEdit();
if (
secondsElapsedSinceLastEdit >=
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
) {
this.setShowProtectedWarning(true);
} else {
const secondsUntilTheNextCheck =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
this.startNoteProtectionInactivityTimer(secondsUntilTheNextCheck);
}
}
startNoteProtectionInactivityTimer(timerDurationInSeconds: number): void {
this.clearNoteProtectionInactivityTimer();
this.protectionTimeoutId = setTimeout(() => {
this.hideProtectedNoteIfInactive();
}, timerDurationInSeconds * 1000);
}
clearNoteProtectionInactivityTimer(): void {
if (this.protectionTimeoutId) {
clearTimeout(this.protectionTimeoutId);
}
}
async handleEditorNoteChange() {
this.clearNoteProtectionInactivityTimer();
this.cancelPendingSetStatus();
const note = this.editor.note;
const showProtectedWarning =
note.protected && !this.application.hasProtectionSources();
note.protected &&
(!this.application.hasProtectionSources() ||
this.application.getProtectionSessionExpiryDate().getTime() <
Date.now());
this.requireAuthenticationForProtectedNote =
note.protected && this.application.hasProtectionSources();
this.setShowProtectedWarning(showProtectedWarning);
await this.setState({
showActionsMenu: false,
@@ -288,6 +342,13 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
async dismissProtectedWarning() {
let showNoteContents = true;
if (this.application.hasProtectionSources()) {
showNoteContents = await this.application.authorizeNoteAccess(this.note);
}
if (!showNoteContents) {
return;
}
this.setShowProtectedWarning(false);
this.focusTitle();
}

View File

@@ -788,6 +788,15 @@
}
}
.sn-button {
&.normal-focus-brightness {
&:hover,
&:focus {
filter: brightness(100%);
}
}
}
@media screen and (max-width: $screen-md-min) {
.sn-component {
.md\:hidden {

View File

@@ -18,6 +18,7 @@
"lint": "eslint --fix app/assets/javascripts",
"tsc": "tsc --project app/assets/javascripts/tsconfig.json",
"test": "jest --config app/assets/javascripts/jest.config.js",
"test:coverage": "yarn test --coverage",
"prepare": "husky install"
},
"devDependencies": {
@@ -49,10 +50,10 @@
"eslint-plugin-react-hooks": "^4.2.1-beta-149b420f6-20211119",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.4.0",
"husky": "^7.0.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1",
"jest-transform-pug": "^0.1.0",
"husky": "^7.0.4",
"lint-staged": ">=10",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^2.4.3",
@@ -85,7 +86,7 @@
"@reach/listbox": "^0.16.2",
"@standardnotes/features": "1.10.2",
"@standardnotes/sncrypto-web": "1.5.3",
"@standardnotes/snjs": "2.20.5",
"@standardnotes/snjs": "^2.23.0",
"mobx": "^6.3.5",
"mobx-react-lite": "^3.2.2",
"preact": "^10.5.15",

View File

@@ -2614,10 +2614,10 @@
buffer "^6.0.3"
libsodium-wrappers "^0.7.9"
"@standardnotes/snjs@2.20.5":
version "2.20.5"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.20.5.tgz#17a12999287e5cd4eb282a455628a9293cee1e1f"
integrity sha512-3cOREPX1XSjyYuLngGrsTJK8mKX3NvFCxld0u5fTsAh42rvvV+2M3vhEp+T99JQBfVu5WzBm5SLj12Js2KNmKg==
"@standardnotes/snjs@^2.23.0":
version "2.23.0"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.23.0.tgz#33e131ede9f37d76414ab17d0430600b4772d550"
integrity sha512-B/GMNeTmaZ9L6t+HyrSOulR+fqIza2f8qK2cU6wTUmakPlErA02H3FNqgjXpCqIEIzvD8edNLDa/nJnlfWoklA==
dependencies:
"@standardnotes/auth" "^3.8.1"
"@standardnotes/common" "^1.2.1"