Merge pull request #576 from vardan-arm/account-menu-splitted

refactor: split "Account Menu" component into multiple components
This commit is contained in:
Antonella Sgarlatta
2021-06-23 14:11:51 -03:00
committed by GitHub
18 changed files with 1346 additions and 1025 deletions

View File

@@ -7,7 +7,7 @@
"prettier",
"plugin:react-hooks/recommended"
],
"plugins": ["@typescript-eslint", "react"],
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"parserOptions": {
"project": "./app/assets/javascripts/tsconfig.json"
},
@@ -17,7 +17,9 @@
"no-console": "off",
"semi": 1,
"camelcase": "warn",
"sort-imports": "off"
"sort-imports": "off",
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
"react-hooks/exhaustive-deps": "error" // Checks effect dependencies
},
"env": {
"browser": true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
import { confirmDialog } from '@Services/alertService';
import {
STRING_ACCOUNT_MENU_UNCHECK_MERGE,
STRING_GENERATING_LOGIN_KEYS,
STRING_GENERATING_REGISTER_KEYS,
STRING_NON_MATCHING_PASSWORDS
} from '@/strings';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import TargetedKeyboardEvent = JSXInternal.TargetedKeyboardEvent;
import { WebApplication } from '@/ui_models/application';
import { useEffect, useRef, useState } from 'preact/hooks';
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
appState: AppState;
}
const Authentication = observer(({
application,
appState,
}: Props) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirmation, setPasswordConfirmation] = useState<string>('');
const [status, setStatus] = useState<string | undefined>(undefined);
const [isEmailFocused, setIsEmailFocused] = useState(false);
const [isEphemeral, setIsEphemeral] = useState(false);
const [isStrictSignIn, setIsStrictSignIn] = useState(false);
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
const {
server,
notesAndTagsCount,
showLogin,
showRegister,
setShowLogin,
setShowRegister,
setServer,
closeAccountMenu
} = appState.accountMenu;
const user = application.getUser();
useEffect(() => {
if (isEmailFocused) {
emailInputRef.current.focus();
setIsEmailFocused(false);
}
}, [isEmailFocused]);
// Reset password and confirmation fields when hiding the form
useEffect(() => {
if (!showLogin && !showRegister) {
setPassword('');
setPasswordConfirmation('');
}
}, [showLogin, showRegister]);
const handleHostInputChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setServer(value);
application.setHost(value);
};
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
const passwordConfirmationInputRef = useRef<HTMLInputElement>();
const handleSignInClick = () => {
setShowLogin(true);
setIsEmailFocused(true);
};
const handleRegisterClick = () => {
setShowRegister(true);
setIsEmailFocused(true);
};
const blurAuthFields = () => {
emailInputRef.current.blur();
passwordInputRef.current.blur();
passwordConfirmationInputRef.current?.blur();
};
const login = async () => {
setStatus(STRING_GENERATING_LOGIN_KEYS);
setIsAuthenticating(true);
const response = await application.signIn(
email,
password,
isStrictSignIn,
isEphemeral,
shouldMergeLocal
);
const error = response.error;
if (!error) {
setIsAuthenticating(false);
setPassword('');
setShowLogin(false);
closeAccountMenu();
return;
}
setShowLogin(true);
setStatus(undefined);
setPassword('');
if (error.message) {
await application.alertService.alert(error.message);
}
setIsAuthenticating(false);
};
const register = async () => {
if (passwordConfirmation !== password) {
application.alertService.alert(STRING_NON_MATCHING_PASSWORDS);
return;
}
setStatus(STRING_GENERATING_REGISTER_KEYS);
setIsAuthenticating(true);
const response = await application.register(
email,
password,
isEphemeral,
shouldMergeLocal
);
const error = response.error;
if (error) {
setStatus(undefined);
setIsAuthenticating(false);
application.alertService.alert(error.message);
} else {
setIsAuthenticating(false);
setShowRegister(false);
closeAccountMenu();
}
};
const handleAuthFormSubmit = (event:
TargetedEvent<HTMLFormElement> |
TargetedMouseEvent<HTMLButtonElement> |
TargetedKeyboardEvent<HTMLButtonElement>
) => {
event.preventDefault();
if (!email || !password) {
return;
}
blurAuthFields();
if (showLogin) {
login();
} else {
register();
}
};
const handleKeyPressKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
handleAuthFormSubmit(event as TargetedKeyboardEvent<HTMLButtonElement>);
}
};
const handlePasswordChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPassword(value);
};
const handleEmailChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setEmail(value);
};
const handlePasswordConfirmationChange = (event: TargetedEvent<HTMLInputElement>) => {
const { value } = event.target as HTMLInputElement;
setPasswordConfirmation(value);
};
const handleMergeLocalData = async (event: TargetedEvent<HTMLInputElement>) => {
const { checked } = event.target as HTMLInputElement;
setShouldMergeLocal(checked);
if (!checked) {
const confirmResult = await confirmDialog({
text: STRING_ACCOUNT_MENU_UNCHECK_MERGE,
confirmButtonStyle: 'danger'
});
setShouldMergeLocal(!confirmResult);
}
};
return (
<>
{!user && !showLogin && !showRegister && (
<div className="sk-panel-section sk-panel-hero">
<div className="sk-panel-row">
<div className="sk-h1">Sign in or register to enable sync and end-to-end encryption.</div>
</div>
<div className="flex my-1">
<button
className="sn-button info flex-grow text-base py-3 mr-1.5"
onClick={handleSignInClick}
>
Sign In
</button>
<button
className="sn-button info flex-grow text-base py-3 ml-1.5"
onClick={handleRegisterClick}
>
Register
</button>
</div>
<div className="sk-panel-row sk-p">
Standard Notes is free on every platform, and comes
standard with sync and encryption.
</div>
</div>
)}
{(showLogin || showRegister) && (
<div className="sk-panel-section">
<div className="sk-panel-section-title">
{showLogin ? 'Sign In' : 'Register'}
</div>
<form className="sk-panel-form" onSubmit={handleAuthFormSubmit} noValidate>
<div className="sk-panel-section">
<input
className="sk-input contrast"
name="email"
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Email"
required
spellcheck={false}
ref={emailInputRef}
/>
<input
className="sk-input contrast"
name="password"
type="password"
value={password}
onChange={handlePasswordChange}
placeholder="Password"
required
onKeyPress={handleKeyPressKeyDown}
onKeyDown={handleKeyPressKeyDown}
ref={passwordInputRef}
/>
{showRegister &&
<input
className="sk-input contrast"
name="password_conf"
type="password"
placeholder="Confirm Password"
required
onKeyPress={handleKeyPressKeyDown}
onKeyDown={handleKeyPressKeyDown}
value={passwordConfirmation}
onChange={handlePasswordConfirmationChange}
ref={passwordConfirmationInputRef}
/>}
<div className="sk-panel-row" />
<button
type="button"
className="sk-a info font-bold text-left p-0 hover:underline mr-1 ml-1"
onClick={() => {
setShowAdvanced(!showAdvanced);
}}>
Advanced Options
</button>
</div>
{showAdvanced && (
<div className="sk-notification unpadded contrast advanced-options sk-panel-row">
<div className="sk-panel-column stretch">
<div className="sk-notification-title sk-panel-row padded-row">
Advanced Options
</div>
<div className="bordered-row padded-row">
<label className="sk-label">Sync Server Domain</label>
<input
className="sk-input sk-base"
name="server"
placeholder="Server URL"
onChange={handleHostInputChange}
value={server}
required
/>
</div>
{showLogin && (
<label className="sk-label padded-row sk-panel-row justify-left">
<div className="sk-horizontal-group tight cursor-pointer">
<input
className="sk-input"
type="checkbox"
checked={isStrictSignIn}
onChange={() => setIsStrictSignIn(prevState => !prevState)}
/>
<p className="sk-p">Use strict sign in</p>
<span>
<a className="info"
href="https://standardnotes.org/help/security" rel="noopener"
target="_blank"
>
(Learn more)
</a>
</span>
</div>
</label>
)}
</div>
</div>
)}
{!isAuthenticating && (
<div className="sk-panel-section form-submit">
<button className="sn-button info text-base py-3 text-center" type="submit"
disabled={isAuthenticating}>
{showLogin ? 'Sign In' : 'Register'}
</button>
</div>
)}
{showRegister && (
<div className="sk-notification neutral">
<div className="sk-notification-title">No Password Reset.</div>
<div className="sk-notification-text">
Because your notes are encrypted using your password,
Standard Notes does not have a password reset option.
You cannot forget your password.
</div>
</div>
)}
{status && (
<div className="sk-panel-section no-bottom-pad">
<div className="sk-horizontal-group">
<div className="sk-spinner small neutral" />
<div className="sk-label">{status}</div>
</div>
</div>
)}
{!isAuthenticating && (
<div className="sk-panel-section no-bottom-pad">
<label className="sk-panel-row justify-left">
<div className="sk-horizontal-group tight cursor-pointer">
<input
type="checkbox"
checked={!isEphemeral}
onChange={() => setIsEphemeral(prevState => !prevState)}
/>
<p className="sk-p">Stay signed in</p>
</div>
</label>
{notesAndTagsCount > 0 && (
<label className="sk-panel-row justify-left">
<div className="sk-horizontal-group tight cursor-pointer">
<input
type="checkbox"
checked={shouldMergeLocal}
onChange={handleMergeLocalData}
/>
<p className="sk-p">Merge local data ({notesAndTagsCount}) notes and tags</p>
</div>
</label>
)}
</div>
)}
</form>
</div>
)}</>
);
});
export default Authentication;

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>(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,81 @@
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
strong {errorReportingIdValue}
</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,68 @@
import { AppState } from '@/ui_models/app_state';
import { useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { observer } from 'mobx-react-lite';
type Props = {
application: WebApplication;
appState: AppState;
}
const Footer = observer(({
application,
appState,
}: Props) => {
const {
showLogin,
showRegister,
setShowLogin,
setShowRegister,
setSigningOut
} = appState.accountMenu;
const user = application.getUser();
const { showBetaWarning, disableBetaWarning: disableAppStateBetaWarning } = appState;
const [appVersion] = useState(() => `v${((window as any).electronAppVersion || application.bridge.appVersion)}`);
const disableBetaWarning = () => {
disableAppStateBetaWarning();
};
const signOut = () => {
setSigningOut(true);
};
const hidePasswordForm = () => {
setShowLogin(false);
setShowRegister(false);
};
return (
<div className="sk-panel-footer">
<div className="sk-panel-row">
<div className="sk-p left neutral">
<span>{appVersion}</span>
{showBetaWarning && (
<span>
<span> (</span>
<a className="sk-a" onClick={disableBetaWarning}>Hide beta warning</a>
<span>)</span>
</span>
)}
</div>
{(showLogin || showRegister) && (
<a className="sk-a right" onClick={hidePasswordForm}>Cancel</a>
)}
{!showLogin && !showRegister && (
<a className="sk-a right danger capitalize" onClick={signOut}>
{user ? 'Sign out' : 'Clear session data'}
</a>
)}
</div>
</div>
);
});
export default Footer;

View File

@@ -0,0 +1,266 @@
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
} 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>();
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 !== 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

@@ -0,0 +1,69 @@
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
import { PasswordWizardType } from '@/types';
import { WebApplication } from '@/ui_models/application';
import { User } from '@standardnotes/snjs/dist/@types/services/api/responses';
type Props = {
appState: AppState;
application: WebApplication;
}
const User = observer(({
appState,
application,
}: Props) => {
const { server, closeAccountMenu } = appState.accountMenu;
const user = application.getUser();
const openPasswordWizard = () => {
closeAccountMenu();
application.presentPasswordWizard(PasswordWizardType.ChangePassword);
};
const openSessionsModal = () => {
closeAccountMenu();
appState.openSessionsModal();
};
return (
<div className="sk-panel-section">
{appState.sync.errorMessage && (
<div className="sk-notification danger">
<div className="sk-notification-title">Sync Unreachable</div>
<div className="sk-notification-text">
Hmm...we can't seem to sync your account.
The reason: {appState.sync.errorMessage}
</div>
<a
className="sk-a info-contrast sk-bold sk-panel-row"
href="https://standardnotes.org/help"
rel="noopener"
target="_blank"
>
Need help?
</a>
</div>
)}
<div className="sk-panel-row">
<div className="sk-panel-column">
<div className="sk-h1 sk-bold wrap">
{(user as User).email}
</div>
<div className="sk-subtitle neutral">
{server}
</div>
</div>
</div>
<div className="sk-panel-row" />
<a className="sk-a info sk-panel-row condensed" onClick={openPasswordWizard}>
Change Password
</a>
<a className="sk-a info sk-panel-row condensed" onClick={openSessionsModal}>
Manage Sessions
</a>
</div>
);
});
export default User;

View File

@@ -0,0 +1,78 @@
import { observer } from 'mobx-react-lite';
import { toDirective } from '@/components/utils';
import { AppState } from '@/ui_models/app_state';
import { WebApplication } from '@/ui_models/application';
import { ConfirmSignoutContainer } from '@/components/ConfirmSignoutModal';
import Authentication from '@/components/AccountMenu/Authentication';
import Footer from '@/components/AccountMenu/Footer';
import User from '@/components/AccountMenu/User';
import Encryption from '@/components/AccountMenu/Encryption';
import Protections from '@/components/AccountMenu/Protections';
import PasscodeLock from '@/components/AccountMenu/PasscodeLock';
import DataBackup from '@/components/AccountMenu/DataBackup';
import ErrorReporting from '@/components/AccountMenu/ErrorReporting';
type Props = {
appState: AppState;
application: WebApplication;
};
const AccountMenu = observer(({ application, appState }: Props) => {
const {
showLogin,
showRegister,
closeAccountMenu
} = appState.accountMenu;
const user = application.getUser();
return (
<div className="sn-component">
<div id="account-panel" className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">Account</div>
<a className="sk-a info close-button" onClick={closeAccountMenu}>Close</a>
</div>
<div className="sk-panel-content">
<Authentication
application={application}
appState={appState}
/>
{!showLogin && !showRegister && (
<div>
{user && (
<User
application={application}
appState={appState}
/>
)}
<Encryption appState={appState} />
<Protections application={application} />
<PasscodeLock
application={application}
appState={appState}
/>
<DataBackup
application={application}
appState={appState}
/>
<ErrorReporting appState={appState} />
</div>
)}
</div>
<ConfirmSignoutContainer
application={application}
appState={appState}
/>
<Footer
application={application}
appState={appState}
/>
</div>
</div>
);
});
export const AccountMenuDirective = toDirective<Props>(
AccountMenu
);

View File

@@ -14,6 +14,7 @@ import TrashSweepIcon from '../../icons/ic-trash-sweep.svg';
import MoreIcon from '../../icons/ic-more.svg';
import TuneIcon from '../../icons/ic-tune.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'pencil-off': PencilOffIcon,
@@ -38,7 +39,7 @@ type Props = {
className: string;
}
export const Icon: React.FC<Props> = ({ type, className }) => {
export const Icon: FunctionalComponent<Props> = ({ type, className }) => {
const IconComponent = ICONS[type];
return <IconComponent className={`sn-icon ${className}`} />;
};

View File

@@ -1,30 +1,60 @@
import { action, computed, makeObservable, observable, runInAction } from 'mobx';
import { ContentType } from '@node_modules/@standardnotes/snjs';
import { ApplicationEvent, ContentType } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { SNItem } from '@node_modules/@standardnotes/snjs/dist/@types/models/core/item';
import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item';
export class AccountMenuState {
show = false;
signingOut = false;
server: string | undefined = undefined;
notesAndTags: SNItem[] = [];
isEncryptionEnabled = false;
encryptionStatusString = '';
isBackupEncrypted = false;
showLogin = false;
showRegister = false;
constructor(
private application: WebApplication,
appEventListeners: (() => void)[]
private appEventListeners: (() => void)[]
) {
makeObservable(this, {
show: observable,
signingOut: observable,
server: observable,
notesAndTags: observable,
isEncryptionEnabled: observable,
encryptionStatusString: observable,
isBackupEncrypted: observable,
showLogin: observable,
showRegister: observable,
setShow: action,
toggleShow: action,
setSigningOut: action,
setIsEncryptionEnabled: action,
setEncryptionStatusString: action,
setIsBackupEncrypted: action,
notesAndTagsCount: computed
});
appEventListeners.push(
this.addAppLaunchedEventObserver();
this.streamNotesAndTags();
}
addAppLaunchedEventObserver = (): void => {
this.appEventListeners.push(
this.application.addEventObserver(async () => {
runInAction(() => {
this.setServer(this.application.getHost());
});
}, ApplicationEvent.Launched)
);
};
streamNotesAndTags = (): void => {
this.appEventListeners.push(
this.application.streamItems(
[ContentType.Note, ContentType.Tag],
() => {
@@ -34,7 +64,7 @@ export class AccountMenuState {
}
)
);
}
};
setShow = (show: boolean): void => {
this.show = show;
@@ -48,6 +78,30 @@ export class AccountMenuState {
this.signingOut = signingOut;
};
setServer = (server: string | undefined): void => {
this.server = server;
};
setIsEncryptionEnabled = (isEncryptionEnabled: boolean): void => {
this.isEncryptionEnabled = isEncryptionEnabled;
};
setEncryptionStatusString = (encryptionStatusString: string): void => {
this.encryptionStatusString = encryptionStatusString;
};
setIsBackupEncrypted = (isBackupEncrypted: boolean): void => {
this.isBackupEncrypted = isBackupEncrypted;
};
setShowLogin = (showLogin: boolean): void => {
this.showLogin = showLogin;
};
setShowRegister = (showRegister: boolean): void => {
this.showRegister = showRegister;
};
toggleShow = (): void => {
this.show = !this.show;
};

View File

@@ -106,7 +106,7 @@ export class AppState {
);
this.accountMenu = new AccountMenuState(
application,
this.appEventObserverRemovers
this.appEventObserverRemovers,
);
this.searchOptions = new SearchOptionsState(
application,

View File

@@ -4,6 +4,14 @@
height: 90vh;
}
.hidden {
display: none;
}
.hover\:underline:hover {
text-decoration: underline;
}
/**
* A button that is just an icon. Separated from .sn-button because there
* is almost no style overlap.
@@ -57,7 +65,7 @@
&[data-state='collapsed'] {
display: none;
}
&.sn-dropdown--animated {
@extend .transition-transform;
@extend .duration-150;

View File

@@ -193,6 +193,10 @@ $screen-md-max: ($screen-lg-min - 1) !default;
.cursor-pointer {
cursor: pointer;
input[type="checkbox"] {
cursor: pointer;
}
}
.fill-current {

View File

@@ -1,6 +1,6 @@
{
"name": "standard-notes-web",
"version": "3.8.8",
"version": "3.8.9",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
@@ -71,7 +71,7 @@
"@reach/checkbox": "^0.13.2",
"@reach/dialog": "^0.13.0",
"@standardnotes/sncrypto-web": "1.2.10",
"@standardnotes/snjs": "2.7.5",
"@standardnotes/snjs": "2.7.6",
"mobx": "^6.1.6",
"mobx-react-lite": "^3.2.0",
"preact": "^10.5.12"

View File

@@ -1936,10 +1936,10 @@
"@standardnotes/sncrypto-common" "^1.2.7"
libsodium-wrappers "^0.7.8"
"@standardnotes/snjs@2.7.5":
version "2.7.5"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.7.5.tgz#5ac5d071912a974acda73bb0720fa66bc5c14448"
integrity sha512-I15S2pwh+7w7pExnXJAUkLmhTySgMdnpUDEpKceufH9uUVvgdsZdz+Kfapv5/pFGOMBL3iDrY30anUSJWSsO1Q==
"@standardnotes/snjs@2.7.6":
version "2.7.6"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.7.6.tgz#f0b965bfad61e93af6803ec2698c02b1489d1073"
integrity sha512-E1Gj02gWvqypVpPed2YCSUnMUi2ZqFsb8NT2Jo8dqFS6Wk3FMzptWlFM/DuTifyzaYsmwPkXv1v5KpeU+dA+CQ==
dependencies:
"@standardnotes/auth" "^2.0.0"
"@standardnotes/sncrypto-common" "^1.2.9"