feat: handle unprotected session expiration (#779)

* 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

* chore: put snjs "beta" version

* fix: run protection timeout when the note is marked as protected

* chore: snjs version bump

* refactor: immediately lock the note if it's marked as "Protected"

* refactor: rename component, directive and some props

* refactor: remove extra check

* refactor: rename the method

* chore: update snjs version

Co-authored-by: Mo Bitar <me@bitar.io>
This commit is contained in:
Vardan Hakobyan
2021-12-20 20:54:37 +04:00
committed by GitHub
parent eb4c8d0091
commit f120af3b43
29 changed files with 469 additions and 817 deletions

View File

@@ -11,7 +11,7 @@
"parserOptions": { "parserOptions": {
"project": "./app/assets/javascripts/tsconfig.json" "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": { "rules": {
"standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals "standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals
"no-throw-literal": 0, "no-throw-literal": 0,

2
.gitignore vendored
View File

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

View File

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

View File

@@ -65,7 +65,7 @@ import { StartApplication } from './startApplication';
import { Bridge } from './services/bridge'; import { Bridge } from './services/bridge';
import { SessionsModalDirective } from './components/SessionsModal'; import { SessionsModalDirective } from './components/SessionsModal';
import { NoAccountWarningDirective } from './components/NoAccountWarning'; import { NoAccountWarningDirective } from './components/NoAccountWarning';
import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning'; import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
import { SearchOptionsDirective } from './components/SearchOptions'; import { SearchOptionsDirective } from './components/SearchOptions';
import { AccountMenuDirective } from './components/AccountMenu'; import { AccountMenuDirective } from './components/AccountMenu';
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
@@ -174,7 +174,7 @@ const startApplication: StartApplication = async function startApplication(
.directive('accountMenu', AccountMenuDirective) .directive('accountMenu', AccountMenuDirective)
.directive('quickSettingsMenu', QuickSettingsMenuDirective) .directive('quickSettingsMenu', QuickSettingsMenuDirective)
.directive('noAccountWarning', NoAccountWarningDirective) .directive('noAccountWarning', NoAccountWarningDirective)
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective) .directive('protectedNotePanel', ProtectedNoteOverlayDirective)
.directive('searchOptions', SearchOptionsDirective) .directive('searchOptions', SearchOptionsDirective)
.directive('confirmSignout', ConfirmSignoutDirective) .directive('confirmSignout', ConfirmSignoutDirective)
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective) .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)

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,36 +0,0 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
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">
Add a passcode or create an account to require authentication to view
this note.
</p>
<div className="mt-4 flex gap-3">
<button
className="sn-button small info"
onClick={() => {
appState.accountMenu.setShow(true);
}}
>
Open account menu
</button>
<button className="sn-button small outlined" onClick={onViewNote}>
View note
</button>
</div>
</div>
);
}
export const NoProtectionsdNoteWarningDirective = toDirective<Props>(
NoProtectionsNoteWarning,
{
onViewNote: '&',
}
);

View File

@@ -0,0 +1,51 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
type Props = {
appState: AppState;
onViewNote: () => void;
hasProtectionSources: boolean;
};
function ProtectedNoteOverlay({
appState,
onViewNote,
hasProtectionSources,
}: Props) {
const instructionText = hasProtectionSources
? 'Authenticate to view this note.'
: 'Add a passcode or create an account to require authentication to view this note.';
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>
<div className="mt-4 flex gap-3">
{!hasProtectionSources && (
<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}
>
{hasProtectionSources ? 'Authenticate' : 'View Note'}
</button>
</div>
</div>
);
}
export const ProtectedNoteOverlayDirective = toDirective<Props>(
ProtectedNoteOverlay,
{
onViewNote: '&',
hasProtectionSources: '=',
}
);

View File

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

View File

@@ -1,7 +1,6 @@
import { FunctionComponent, h, render } from 'preact'; import { FunctionComponent, h, render } from 'preact';
import { unmountComponentAtNode } from 'preact/compat'; import { unmountComponentAtNode } from 'preact/compat';
import { StateUpdater, useCallback, useState } from 'preact/hooks'; import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
import { useEffect } from 'react';
/** /**
* @returns a callback that will close a dropdown if none of its children has * @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 tsConfig = require('./tsconfig.json');
const pathsFromTsconfig = tsConfig.compilerOptions.paths; const pathsFromTsconfig = tsConfig.compilerOptions.paths;
module.exports = { module.exports = {
restoreMocks: true,
clearMocks: true, clearMocks: true,
resetMocks: true,
moduleNameMapper: { moduleNameMapper: {
...pathsToModuleNameMapper(pathsFromTsconfig, { ...pathsToModuleNameMapper(pathsFromTsconfig, {
prefix: '<rootDir>', prefix: '<rootDir>',
@@ -14,7 +17,6 @@ module.exports = {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy', '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
}, },
globals: { globals: {
window: {},
__VERSION__: '1.0.0', __VERSION__: '1.0.0',
__DESKTOP__: false, __DESKTOP__: false,
__WEB__: true, __WEB__: true,

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/Button';
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'; import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings'; import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from '@node_modules/preact/hooks'; 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 { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';

View File

@@ -4,7 +4,12 @@ import { useCallback, useState } from 'preact/hooks';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { ApplicationEvent } from '@standardnotes/snjs'; import { ApplicationEvent } from '@standardnotes/snjs';
import { isSameDay } from '@/utils'; 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'; import { Button } from '@/components/Button';
type Props = { type Props = {
@@ -16,7 +21,9 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
application.clearProtectionSession(); application.clearProtectionSession();
}; };
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources()); const [hasProtections, setHasProtections] = useState(() =>
application.hasProtectionSources()
);
const getProtectionsDisabledUntil = useCallback((): string | null => { const getProtectionsDisabledUntil = useCallback((): string | null => {
const protectionExpiry = application.getProtectionSessionExpiryDate(); const protectionExpiry = application.getProtectionSessionExpiryDate();
@@ -26,7 +33,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
if (isSameDay(protectionExpiry, now)) { if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, { f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', hour: 'numeric',
minute: 'numeric' minute: 'numeric',
}); });
} else { } else {
f = new Intl.DateTimeFormat(undefined, { f = new Intl.DateTimeFormat(undefined, {
@@ -34,7 +41,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
hour: 'numeric', hour: 'numeric',
minute: 'numeric' minute: 'numeric',
}); });
} }
@@ -43,14 +50,23 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
return null; return null;
}, [application]); }, [application]);
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil()); const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(
getProtectionsDisabledUntil()
);
useEffect(() => { useEffect(() => {
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver( const removeUnprotectedSessionBeginObserver = application.addEventObserver(
async () => { async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil()); setProtectionsDisabledUntil(getProtectionsDisabledUntil());
}, },
ApplicationEvent.ProtectionSessionExpiryDateChanged ApplicationEvent.UnprotectedSessionBegan
);
const removeUnprotectedSessionEndObserver = application.addEventObserver(
async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
},
ApplicationEvent.UnprotectedSessionExpired
); );
const removeKeyStatusChangedObserver = application.addEventObserver( const removeKeyStatusChangedObserver = application.addEventObserver(
@@ -61,7 +77,8 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
); );
return () => { return () => {
removeProtectionSessionExpiryDateChangedObserver(); removeUnprotectedSessionBeginObserver();
removeUnprotectedSessionEndObserver();
removeKeyStatusChangedObserver(); removeKeyStatusChangedObserver();
}; };
}, [application, getProtectionsDisabledUntil]); }, [application, getProtectionsDisabledUntil]);
@@ -74,18 +91,27 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title>Protections</Title> <Title>Protections</Title>
{protectionsDisabledUntil {protectionsDisabledUntil ? (
? <Text className="info">Protections are disabled until {protectionsDisabledUntil}.</Text> <Text className="info">
: <Text className="info">Protections are enabled.</Text> Unprotected access expires at {protectionsDisabledUntil}.
}
<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.
</Text> </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> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
); );

View File

@@ -5,7 +5,8 @@ import {
DisclosurePanel, DisclosurePanel,
} from '@reach/disclosure'; } from '@reach/disclosure';
import { FunctionComponent } from 'preact'; 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<{ const DisclosureIconButton: FunctionComponent<{
className?: string; className?: string;

View File

@@ -28,7 +28,7 @@ export class NotesState {
top: 0, top: 0,
left: 0, left: 0,
}; };
contextMenuClickLocation: { x: number, y: number } = { x: 0, y: 0 }; contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 };
contextMenuMaxHeight: number | 'auto' = 'auto'; contextMenuMaxHeight: number | 'auto' = 'auto';
showProtectedWarning = false; showProtectedWarning = false;
@@ -185,7 +185,7 @@ export class NotesState {
this.contextMenuOpen = open; this.contextMenuOpen = open;
} }
setContextMenuClickLocation(location: { x: number, y: number }): void { setContextMenuClickLocation(location: { x: number; y: number }): void {
this.contextMenuClickLocation = location; this.contextMenuClickLocation = location;
} }
@@ -212,7 +212,8 @@ export class NotesState {
// Open up-bottom is default behavior // Open up-bottom is default behavior
let openUpBottom = true; let openUpBottom = true;
const bottomSpace = clientHeight - footerHeight - this.contextMenuClickLocation.y; const bottomSpace =
clientHeight - footerHeight - this.contextMenuClickLocation.y;
const upSpace = this.contextMenuClickLocation.y; const upSpace = this.contextMenuClickLocation.y;
// If not enough space to open up-bottom // If not enough space to open up-bottom
@@ -220,26 +221,18 @@ export class NotesState {
// If there's enough space, open bottom-up // If there's enough space, open bottom-up
if (upSpace > maxContextMenuHeight) { if (upSpace > maxContextMenuHeight) {
openUpBottom = false; openUpBottom = false;
this.setContextMenuMaxHeight( this.setContextMenuMaxHeight('auto');
'auto'
);
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space // Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
} else { } else {
if (upSpace > bottomSpace) { if (upSpace > bottomSpace) {
this.setContextMenuMaxHeight( this.setContextMenuMaxHeight(upSpace - 2);
upSpace - 2
);
openUpBottom = false; openUpBottom = false;
} else { } else {
this.setContextMenuMaxHeight( this.setContextMenuMaxHeight(bottomSpace - 2);
bottomSpace - 2
);
} }
} }
} else { } else {
this.setContextMenuMaxHeight( this.setContextMenuMaxHeight('auto');
'auto'
);
} }
if (openUpBottom) { if (openUpBottom) {
@@ -375,9 +368,7 @@ export class NotesState {
const selectedNotes = Object.values(this.selectedNotes); const selectedNotes = Object.values(this.selectedNotes);
if (protect) { if (protect) {
await this.application.protectNotes(selectedNotes); await this.application.protectNotes(selectedNotes);
if (!this.application.hasProtectionSources()) {
this.setShowProtectedWarning(true); this.setShowProtectedWarning(true);
}
} else { } else {
await this.application.unprotectNotes(selectedNotes); await this.application.unprotectNotes(selectedNotes);
this.setShowProtectedWarning(false); this.setShowProtectedWarning(false);

View File

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

View File

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

View File

@@ -19,9 +19,9 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
needsUnlock?: boolean, needsUnlock?: boolean,
appClass: string, appClass: string,
}> { }> {
public platformString: string public platformString: string;
private notesCollapsed = false private notesCollapsed = false;
private tagsCollapsed = false private tagsCollapsed = false;
/** /**
* To prevent stale state reads (setState is async), * To prevent stale state reads (setState is async),
* challenges is a mutable array * challenges is a mutable array
@@ -108,14 +108,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
/** @override */ /** @override */
async onAppEvent(eventName: ApplicationEvent) { async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName); super.onAppEvent(eventName);
if (eventName === ApplicationEvent.LocalDatabaseReadError) { switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({ alertDialog({
text: 'Unable to load local database. Please restart the app and try again.' text: 'Unable to load local database. Please restart the app and try again.'
}); });
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) { break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({ alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.' 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 */} {/** ProtectionSessionDuration can't just be an input field */}
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div key={prompt.id} className="sk-panel-row"> <div key={prompt.id} className="sk-panel-row">
<div className="sk-horizontal-group"> <div className="sk-horizontal-group mt-3">
<div className="sk-p sk-bold">Remember For</div> <div className="sk-p sk-bold">Allow protected access for</div>
{ProtectionSessionDurations.map((option) => ( {ProtectionSessionDurations.map((option) => (
<a <a
className={ className={
@@ -374,10 +374,13 @@ function ChallengePrompts({
</div> </div>
) : ( ) : (
<div key={prompt.id} className="sk-panel-row"> <div key={prompt.id} className="sk-panel-row">
<form className="w-full" onSubmit={(event) => { <form
className="w-full"
onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
ctrl.submit(); ctrl.submit();
}}> }}
>
<input <input
className="sk-input contrast" className="sk-input contrast"
value={ctrl.state.values[prompt.id]!.value as string | number} 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_NOTES = 'notes';
export const PANEL_NAME_TAGS = 'tags'; export const PANEL_NAME_TAGS = 'tags';
// eslint-disable-next-line no-useless-escape export const EMAIL_REGEX =
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])?)$/; /^([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( protected-note-panel.h-full.flex.justify-center.items-center(
ng-if='self.state.showProtectedWarning' ng-if='self.state.showProtectedWarning'
app-state='self.appState' app-state='self.appState'
has-protection-sources='self.application.hasProtectionSources()'
on-view-note='self.dismissProtectedWarning()' on-view-note='self.dismissProtectedWarning()'
) )
.flex-grow.flex.flex-col( .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, 'setShowProtectedOverlay');
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, PrefKey,
ComponentMutator, ComponentMutator,
PayloadSource, PayloadSource,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { isDesktopApplication } from '@/utils'; import { isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService'; 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 */ /** Passed through template */
readonly application!: WebApplication; readonly application!: WebApplication;
readonly editor!: Editor; readonly editor!: Editor;
@@ -108,6 +109,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
private removeTabObserver?: any; private removeTabObserver?: any;
private removeComponentsObserver!: () => void; private removeComponentsObserver!: () => void;
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
/* @ngInject */ /* @ngInject */
constructor($timeout: ng.ITimeoutService) { constructor($timeout: ng.ITimeoutService) {
@@ -124,14 +126,15 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.setScrollPosition = this.setScrollPosition.bind(this); this.setScrollPosition = this.setScrollPosition.bind(this);
this.resetScrollPosition = this.resetScrollPosition.bind(this); this.resetScrollPosition = this.resetScrollPosition.bind(this);
this.onEditorLoad = () => { this.onEditorLoad = () => {
this.application!.getDesktopService().redoSearch(); this.application.getDesktopService().redoSearch();
}; };
} }
deinit() { deinit() {
this.clearNoteProtectionInactivityTimer();
this.editor.clearNoteChangeListener(); this.editor.clearNoteChangeListener();
this.removeComponentsObserver(); this.removeComponentsObserver();
(this.removeComponentsObserver as any) = undefined; (this.removeComponentsObserver as unknown) = undefined;
this.removeTrashKeyObserver(); this.removeTrashKeyObserver();
this.removeTrashKeyObserver = undefined; this.removeTrashKeyObserver = undefined;
this.removeTabObserver && this.removeTabObserver(); this.removeTabObserver && this.removeTabObserver();
@@ -143,8 +146,8 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.unregisterComponent = undefined; this.unregisterComponent = undefined;
this.saveTimeout = undefined; this.saveTimeout = undefined;
this.statusTimeout = undefined; this.statusTimeout = undefined;
(this.onPanelResizeFinish as any) = undefined; (this.onPanelResizeFinish as unknown) = undefined;
(this.editorMenuOnSelect as any) = undefined; (this.editorMenuOnSelect as unknown) = undefined;
super.deinit(); super.deinit();
} }
@@ -229,7 +232,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
} }
/** @override */ /** @override */
onAppEvent(eventName: ApplicationEvent) { async onAppEvent(eventName: ApplicationEvent) {
switch (eventName) { switch (eventName) {
case ApplicationEvent.PreferencesChanged: case ApplicationEvent.PreferencesChanged:
this.reloadPreferences(); this.reloadPreferences();
@@ -262,15 +265,63 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
desc: 'Changes not saved', desc: 'Changes not saved',
}); });
break; break;
case ApplicationEvent.UnprotectedSessionBegan: {
this.setShowProtectedOverlay(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.setShowProtectedOverlay(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() { async handleEditorNoteChange() {
this.clearNoteProtectionInactivityTimer();
this.cancelPendingSetStatus(); this.cancelPendingSetStatus();
const note = this.editor.note; const note = this.editor.note;
const showProtectedWarning = const showProtectedWarning =
note.protected && !this.application.hasProtectionSources(); note.protected &&
this.setShowProtectedWarning(showProtectedWarning); (!this.application.hasProtectionSources() ||
this.application.getProtectionSessionExpiryDate().getTime() <
Date.now());
this.setShowProtectedOverlay(showProtectedWarning);
await this.setState({ await this.setState({
showActionsMenu: false, showActionsMenu: false,
showEditorMenu: false, showEditorMenu: false,
@@ -288,7 +339,14 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
} }
async dismissProtectedWarning() { async dismissProtectedWarning() {
this.setShowProtectedWarning(false); let showNoteContents = true;
if (this.application.hasProtectionSources()) {
showNoteContents = await this.application.authorizeNoteAccess(this.note);
}
if (!showNoteContents) {
return;
}
this.setShowProtectedOverlay(false);
this.focusTitle(); this.focusTitle();
} }
@@ -598,7 +656,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.lastEditorFocusEventSource = undefined; this.lastEditorFocusEventSource = undefined;
} }
setShowProtectedWarning(show: boolean) { setShowProtectedOverlay(show: boolean) {
this.appState.notes.setShowProtectedWarning(show); this.appState.notes.setShowProtectedWarning(show);
} }

View File

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

View File

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

View File

@@ -2561,33 +2561,26 @@
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@sinonjs/commons" "^1.7.0"
"@standardnotes/auth@3.8.3": "@standardnotes/auth@3.8.3", "@standardnotes/auth@^3.8.1":
version "3.8.3" version "3.8.3"
resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.8.3.tgz#6e627c1a1a9ebf91d97f52950d099bf7704382e3" resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.8.3.tgz#6e627c1a1a9ebf91d97f52950d099bf7704382e3"
integrity sha512-wz056b3pv8IIX74lYaqjCUvnw3NSow+ex5pn/VlGxg8r7gq19WsmgyXP2BoE7nqKddO1JMlFok+4gdnutYF0Cw== integrity sha512-wz056b3pv8IIX74lYaqjCUvnw3NSow+ex5pn/VlGxg8r7gq19WsmgyXP2BoE7nqKddO1JMlFok+4gdnutYF0Cw==
dependencies: dependencies:
"@standardnotes/common" "^1.2.1" "@standardnotes/common" "^1.2.1"
"@standardnotes/auth@^3.8.1":
version "3.8.1"
resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.8.1.tgz#4197fb2f7e223c6bd13a870a3feac3c73294fb3c"
integrity sha512-Q2/81dgFGIGuYlQ4VnSjGRsDB0Qw0tQP/qsiuV+DQj+wdp5Wy5WX3Q4g+p2PNvoyEAYgbuduEHZfWuTLAaIdyw==
dependencies:
"@standardnotes/common" "^1.2.1"
"@standardnotes/common@^1.2.1": "@standardnotes/common@^1.2.1":
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.2.1.tgz#9db212db86ccbf08b347da02549b3dbe4bedbb02" resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.2.1.tgz#9db212db86ccbf08b347da02549b3dbe4bedbb02"
integrity sha512-HilBxS50CBlC6TJvy1mrnhGVDzOH63M/Jf+hyMxQ0Vt1nYzpd0iyxVEUrgMh7ZiyO1b9CLnCDED99Jy9rnZWVQ== integrity sha512-HilBxS50CBlC6TJvy1mrnhGVDzOH63M/Jf+hyMxQ0Vt1nYzpd0iyxVEUrgMh7ZiyO1b9CLnCDED99Jy9rnZWVQ==
"@standardnotes/domain-events@^2.5.1": "@standardnotes/domain-events@^2.5.1":
version "2.5.1" version "2.10.0"
resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.5.1.tgz#e6433e940ae616683d1c24f76133c70755504c44" resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.10.0.tgz#719c430d1736daffcb4233aa3381b58280564dc0"
integrity sha512-p0VB4Al/ZcVqcj9ztU7TNqzc3jjjG6/U7x9lBW/QURHxpB+PnwJq3kFU5V5JA9QpCOYlXLT71CMERMf/O5QX6g== integrity sha512-8jvkhNjoYrXN81RA8Q4vGEKH9R002Y/aEK29GyxmQmijT5+JwlA4f0ySycz5sJxWGULohL1k96RueYPs97hV3g==
dependencies: dependencies:
"@standardnotes/auth" "^3.8.1" "@standardnotes/auth" "^3.8.1"
"@standardnotes/features@1.10.2", "@standardnotes/features@^1.10.2": "@standardnotes/features@1.10.2":
version "1.10.2" version "1.10.2"
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.10.2.tgz#a0783f66c00e21cb7692edc0cea95ec25a0253a5" resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.10.2.tgz#a0783f66c00e21cb7692edc0cea95ec25a0253a5"
integrity sha512-Zh6EMjli4mL6jlXEhMyU3qYIKFJj5kuhbxtHXiErUGIDy+s1hHY+THFFO53Jdga2+8wgcATWlmSBY7dieVA8uA== integrity sha512-Zh6EMjli4mL6jlXEhMyU3qYIKFJj5kuhbxtHXiErUGIDy+s1hHY+THFFO53Jdga2+8wgcATWlmSBY7dieVA8uA==
@@ -2595,10 +2588,18 @@
"@standardnotes/auth" "3.8.3" "@standardnotes/auth" "3.8.3"
"@standardnotes/common" "^1.2.1" "@standardnotes/common" "^1.2.1"
"@standardnotes/features@^1.10.3":
version "1.10.3"
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.10.3.tgz#f5824342446e69f006ea8ac8916203d1d3992f21"
integrity sha512-PU4KthoDr6NL1bOfKnYV1WXYqRu1/IcdkZkJa2LHcYMPduUjDUKO6qRK73dF0+EEI1U+YXY/9rHyfadGwd0Ymg==
dependencies:
"@standardnotes/auth" "3.8.3"
"@standardnotes/common" "^1.2.1"
"@standardnotes/settings@^1.2.1": "@standardnotes/settings@^1.2.1":
version "1.2.1" version "1.4.0"
resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.2.1.tgz#4c7656ea86d784a2f77c70acc89face5d28da024" resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.4.0.tgz#b8f43383fa1b469d609f5ac6c2ce571efb8bfe71"
integrity sha512-EhCDtQKcVzY6cJ6qXCkAiA3sJ3Wj/q0L0ZVYq+tCXd0jaxmZ8fSk5YNqdwJfjmNXsqtuh7xq6eA2dcXd1fD9VQ== integrity sha512-mGptrIaM/3UWOkc9xmzuRRM2A75caX6vyqCeKhyqPdM3ZR/YpYH7I6qYDsO6wpkoF3soD2nRJ6pLV7HBjGdGag==
"@standardnotes/sncrypto-common@^1.5.2": "@standardnotes/sncrypto-common@^1.5.2":
version "1.5.2" version "1.5.2"
@@ -2614,15 +2615,15 @@
buffer "^6.0.3" buffer "^6.0.3"
libsodium-wrappers "^0.7.9" libsodium-wrappers "^0.7.9"
"@standardnotes/snjs@2.23.2": "@standardnotes/snjs@2.25.0":
version "2.23.2" version "2.25.0"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.23.2.tgz#16f76c7e4278be9f315e90b96dabfcee2d6b7961" resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.25.0.tgz#742ee451547c1f36d29bb3e58678068d6a455520"
integrity sha512-CPLvizemAYRO0XaDOD8pnjrzYSkvcw/UI0V6hlfe9VkJ2jLHotbDSeHdzFotjosX5leGB4UXemjd09EzDNdqpA== integrity sha512-Sb2sZItuxWAFepbNyqGvH8CIC436VirEjAqc0NK9+1CK0wqPLfpCiDBEkTzsQa2UovnoKu/p4tpTrMnx3FvK2A==
dependencies: dependencies:
"@standardnotes/auth" "^3.8.1" "@standardnotes/auth" "^3.8.1"
"@standardnotes/common" "^1.2.1" "@standardnotes/common" "^1.2.1"
"@standardnotes/domain-events" "^2.5.1" "@standardnotes/domain-events" "^2.5.1"
"@standardnotes/features" "^1.10.2" "@standardnotes/features" "^1.10.3"
"@standardnotes/settings" "^1.2.1" "@standardnotes/settings" "^1.2.1"
"@standardnotes/sncrypto-common" "^1.5.2" "@standardnotes/sncrypto-common" "^1.5.2"