Revert "feat: handle unprotected session expiration (#747)"

This reverts commit 8db549f6f6.
This commit is contained in:
Karol Sójko 2021-12-15 15:26:31 +01:00
parent 0e4757d426
commit 2e168df929
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
26 changed files with 755 additions and 418 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", "__mocks__"],
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js"],
"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,5 +50,3 @@ yarn-error.log
package-lock.json
codeqldb
coverage

View file

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

View file

@ -0,0 +1,180 @@
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

@ -0,0 +1,33 @@
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

@ -0,0 +1,80 @@
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

@ -0,0 +1,272 @@
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

@ -0,0 +1,100 @@
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,41 +1,27 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
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.';
type Props = { appState: AppState; onViewNote: () => void };
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">{instructionText}</p>
<p className="text-lg mt-2 w-full">
Add a passcode or create an account to require authentication to view
this note.
</p>
<div className="mt-4 flex gap-3">
{!requireAuthenticationForProtectedNote && (
<button
className="sn-button small info"
onClick={() => {
appState.accountMenu.setShow(true);
}}
>
Open account menu
</button>
)}
<button
className="sn-button small outlined normal-focus-brightness"
onClick={onViewNote}
className="sn-button small info"
onClick={() => {
appState.accountMenu.setShow(true);
}}
>
{requireAuthenticationForProtectedNote ? 'Authenticate' : 'View Note'}
Open account menu
</button>
<button className="sn-button small outlined" onClick={onViewNote}>
View note
</button>
</div>
</div>
@ -46,6 +32,5 @@ 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 { useEffect, useRef, useState } from 'preact/hooks';
import { useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import VisuallyHidden from '@reach/visually-hidden';
import {
@ -11,6 +11,7 @@ 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,6 +1,7 @@
import { FunctionComponent, h, render } from 'preact';
import { unmountComponentAtNode } from 'preact/compat';
import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { useEffect } from 'react';
/**
* @returns a callback that will close a dropdown if none of its children has

View file

@ -1,13 +1,10 @@
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>',
@ -17,6 +14,7 @@ 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 'mobx-react-lite';
import { observer } from '@node_modules/mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact';

View file

@ -4,12 +4,7 @@ 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 = {
@ -21,9 +16,7 @@ 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();
@ -33,7 +26,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, {
@ -41,7 +34,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric',
minute: 'numeric'
});
}
@ -50,23 +43,14 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
return null;
}, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(
getProtectionsDisabledUntil()
);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
useEffect(() => {
const removeUnprotectedSessionBeginObserver = application.addEventObserver(
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionBegan
);
const removeUnprotectedSessionEndObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionExpired
ApplicationEvent.ProtectionSessionExpiryDateChanged
);
const removeKeyStatusChangedObserver = application.addEventObserver(
@ -77,8 +61,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
);
return () => {
removeUnprotectedSessionBeginObserver();
removeUnprotectedSessionEndObserver();
removeProtectionSessionExpiryDateChangedObserver();
removeKeyStatusChangedObserver();
};
}, [application, getProtectionsDisabledUntil]);
@ -91,28 +74,19 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
<PreferencesGroup>
<PreferencesSegment>
<Title>Protections</Title>
{protectionsDisabledUntil ? (
<Text className="info">
Unprotected access expires at {protectionsDisabledUntil}.
</Text>
) : (
<Text className="info">Protections are enabled.</Text>
)}
{protectionsDisabledUntil
? <Text className="info">Protections are disabled until {protectionsDisabledUntil}.</Text>
: <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.
Actions like viewing protected notes, exporting decrypted backups,
or revoking an active session, require additional authentication
like entering your account password or application passcode.
</Text>
{protectionsDisabledUntil && (
<Button
className="mt-3"
type="primary"
label="End Unprotected Access"
onClick={enableProtections}
/>
)}
{protectionsDisabledUntil &&
<Button className="mt-3" type="primary" label="Enable Protections" onClick={enableProtections} />
}
</PreferencesSegment>
</PreferencesGroup>
</PreferencesGroup >
);
};

View file

@ -5,8 +5,7 @@ import {
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import { MouseEventHandler } from 'react';
import { useState, useRef, useEffect } from 'preact/hooks';
import { useState, useRef, useEffect, MouseEventHandler } from 'react';
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,10 +25,7 @@ export class SearchOptionsState {
appObservers.push(
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents();
}, ApplicationEvent.UnprotectedSessionBegan),
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents();
}, ApplicationEvent.UnprotectedSessionExpired)
}, ApplicationEvent.ProtectionSessionExpiryDateChanged)
);
}
@ -41,17 +38,21 @@ export class SearchOptionsState {
};
refreshIncludeProtectedContents = (): void => {
this.includeProtectedContents =
this.application.hasUnprotectedAccessSession();
if (
this.includeProtectedContents &&
this.application.areProtectionsEnabled()
) {
this.includeProtectedContents = false;
}
};
toggleIncludeProtectedContents = async (): Promise<void> => {
if (this.includeProtectedContents) {
this.includeProtectedContents = false;
} else {
await this.application.authorizeSearchingProtectedNotesText();
const authorized = await this.application.authorizeSearchingProtectedNotesText();
runInAction(() => {
this.refreshIncludeProtectedContents();
this.includeProtectedContents = authorized;
});
}
};

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, data: any) => {
this.onAppEvent(eventName, data);
this.unsubApp = this.application!.addEventObserver(async (eventName) => {
this.onAppEvent(eventName);
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, data?: any) {
onAppEvent(eventName: ApplicationEvent) {
/** 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,17 +108,14 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.'
});
break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.'
});
break;
if (eventName === ApplicationEvent.LocalDatabaseReadError) {
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.'
});
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.'
});
}
}

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 mt-3">
<div className="sk-p sk-bold">Allow protected access for</div>
<div className="sk-horizontal-group">
<div className="sk-p sk-bold">Remember For</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
@ -374,13 +374,10 @@ function ChallengePrompts({
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
ctrl.submit();
}}
>
<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';
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 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])?)$/;

View file

@ -2,7 +2,6 @@
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

@ -1,196 +0,0 @@
/**
* @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,7 +16,6 @@ import {
PrefKey,
ComponentMutator,
PayloadSource,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs';
import { isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
@ -90,7 +89,7 @@ function sortAlphabetically(array: SNComponent[]): SNComponent[] {
);
}
export class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
/** Passed through template */
readonly application!: WebApplication;
readonly editor!: Editor;
@ -109,8 +108,6 @@ export 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) {
@ -127,15 +124,14 @@ export 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 unknown) = undefined;
(this.removeComponentsObserver as any) = undefined;
this.removeTrashKeyObserver();
this.removeTrashKeyObserver = undefined;
this.removeTabObserver && this.removeTabObserver();
@ -147,8 +143,8 @@ export class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.unregisterComponent = undefined;
this.saveTimeout = undefined;
this.statusTimeout = undefined;
(this.onPanelResizeFinish as unknown) = undefined;
(this.editorMenuOnSelect as unknown) = undefined;
(this.onPanelResizeFinish as any) = undefined;
(this.editorMenuOnSelect as any) = undefined;
super.deinit();
}
@ -233,7 +229,7 @@ export class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences();
@ -266,64 +262,14 @@ export 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() ||
this.application.getProtectionSessionExpiryDate().getTime() <
Date.now());
this.requireAuthenticationForProtectedNote =
note.protected && this.application.hasProtectionSources();
note.protected && !this.application.hasProtectionSources();
this.setShowProtectedWarning(showProtectedWarning);
await this.setState({
showActionsMenu: false,
@ -342,13 +288,6 @@ export 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,15 +788,6 @@
}
}
.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,7 +18,6 @@
"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": {
@ -50,10 +49,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",
@ -86,7 +85,7 @@
"@reach/listbox": "^0.16.2",
"@standardnotes/features": "1.10.2",
"@standardnotes/sncrypto-web": "1.5.3",
"@standardnotes/snjs": "^2.23.0",
"@standardnotes/snjs": "2.20.5",
"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.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==
"@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==
dependencies:
"@standardnotes/auth" "^3.8.1"
"@standardnotes/common" "^1.2.1"