diff --git a/.eslintrc b/.eslintrc index 28f7be1df..74fa349e3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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 diff --git a/app/assets/javascripts/components/AccountMenu.tsx b/app/assets/javascripts/components/AccountMenu.tsx deleted file mode 100644 index 1c4e30a5b..000000000 --- a/app/assets/javascripts/components/AccountMenu.tsx +++ /dev/null @@ -1,1009 +0,0 @@ -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 { useCallback, useEffect, useRef, useState } from 'preact/hooks'; - -import { isDesktopApplication, isSameDay, preventRefreshing } from '@/utils'; -import { storage, StorageKey } from '@Services/localStorage'; -import { disableErrorReporting, enableErrorReporting, errorReportingId } from '@Services/errorReporting'; -import { - STRING_ACCOUNT_MENU_UNCHECK_MERGE, - STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE, - STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, - STRING_E2E_ENABLED, - STRING_ENC_NOT_ENABLED, - STRING_GENERATING_LOGIN_KEYS, - STRING_GENERATING_REGISTER_KEYS, - STRING_IMPORT_SUCCESS, - STRING_INVALID_IMPORT_FILE, - STRING_LOCAL_ENC_ENABLED, - STRING_NON_MATCHING_PASSCODES, - STRING_NON_MATCHING_PASSWORDS, - STRING_UNSUPPORTED_BACKUP_FILE_VERSION, - StringImportError, - StringUtils -} from '@/strings'; -import { ApplicationEvent, BackupFile } from '@node_modules/@standardnotes/snjs'; -import { PasswordWizardType } from '@/types'; -import { JSXInternal } from '@node_modules/preact/src/jsx'; -import TargetedEvent = JSXInternal.TargetedEvent; -import TargetedKeyboardEvent = JSXInternal.TargetedKeyboardEvent; -import TargetedMouseEvent = JSXInternal.TargetedMouseEvent; -import { alertDialog, confirmDialog } from '@Services/alertService'; -import { ConfirmSignoutContainer } from '@/components/ConfirmSignoutModal'; - -type Props = { - appState: AppState; - application: WebApplication; -}; - -const AccountMenu = observer(({ application, appState }: Props) => { - const getProtectionsDisabledUntil = (): 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; - }; - - const passcodeInputRef = useRef(); - const emailInputRef = useRef(); - const passwordInputRef = useRef(); - const passwordConfirmationInputRef = useRef(); - - const [showLogin, setShowLogin] = useState(false); - const [showRegister, setShowRegister] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [passwordConfirmation, setPasswordConfirmation] = useState(undefined); - const [status, setStatus] = useState(undefined); - - const [isEphemeral, setIsEphemeral] = useState(false); - const [isStrictSignIn, setIsStrictSignIn] = useState(false); - - const [passcode, setPasscode] = useState(undefined); - const [passcodeConfirmation, setPasscodeConfirmation] = useState(undefined); - - const [encryptionStatusString, setEncryptionStatusString] = useState(undefined); - const [isEncryptionEnabled, setIsEncryptionEnabled] = useState(false); - const [shouldMergeLocal, setShouldMergeLocal] = useState(true); - - const [server, setServer] = useState(application.getHost()); - const [url, setUrl] = useState(application.getHost()); - const [showPasscodeForm, setShowPasscodeForm] = useState(false); - const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState(null); - const [isImportDataLoading, setIsImportDataLoading] = useState(false); - - const [isErrorReportingEnabled] = useState(() => storage.get(StorageKey.DisableErrorReporting) === false); - const [appVersion] = useState(() => `v${((window as any).electronAppVersion || application.bridge.appVersion)}`); - const [hasPasscode, setHasPasscode] = useState(application.hasPasscode()); - const [isBackupEncrypted, setIsBackupEncrypted] = useState(isEncryptionEnabled); - const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil()); - const [user, setUser] = useState(application.getUser()); - const [canAddPasscode, setCanAddPasscode] = useState(!application.isEphemeralSession()); - const [hasProtections] = useState(application.hasProtectionSources()); - - const [isEmailFocused, setIsEmailFocused] = useState(false); - const [isPasscodeFocused, setIsPasscodeFocused] = useState(false); - - const { notesAndTagsCount } = appState.accountMenu; - - const refreshCredentialState = () => { - setUser(application.getUser()); - setCanAddPasscode(!application.isEphemeralSession()); - setHasPasscode(application.hasPasscode()); - setShowPasscodeForm(false); - }; - - const loadHost = () => { - const host = application.getHost(); - setServer(host); - setUrl(host); - }; - - const reloadAutoLockInterval = useCallback(async () => { - const interval = await application.getAutolockService().getAutoLockInterval(); - setSelectedAutoLockInterval(interval); - }, [application]); - - const refreshEncryptionStatus = () => { - const hasUser = application.hasAccount(); - const hasPasscode = application.hasPasscode(); - - setHasPasscode(hasPasscode); - - const encryptionEnabled = hasUser || hasPasscode; - - const newEncryptionStatusString = hasUser - ? STRING_E2E_ENABLED - : hasPasscode - ? STRING_LOCAL_ENC_ENABLED - : STRING_ENC_NOT_ENABLED; - - setEncryptionStatusString(newEncryptionStatusString); - setIsEncryptionEnabled(encryptionEnabled); - setIsBackupEncrypted(encryptionEnabled); - }; - - const errorReportingIdValue = errorReportingId(); - const keyStorageInfo = StringUtils.keyStorageInfo(application); - const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions(); - const showBetaWarning = appState.showBetaWarning; - - const closeAccountMenu = () => { - appState.accountMenu.closeAccountMenu(); - }; - - 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 as string, - password, - isStrictSignIn, - isEphemeral, - shouldMergeLocal - ); - const error = response.error; - if (!error) { - setIsAuthenticating(false); - setPassword(''); - - 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 as string, - password, - isEphemeral, - shouldMergeLocal - ); - - const error = response.error; - if (error) { - setStatus(undefined); - setIsAuthenticating(false); - - application.alertService.alert(error.message); - } else { - setIsAuthenticating(false); - closeAccountMenu(); - } - }; - - const handleAuthFormSubmit = (event: - TargetedEvent | - TargetedMouseEvent | - TargetedKeyboardEvent - ) => { - event.preventDefault(); - - if (!email || !password) { - return; - } - - blurAuthFields(); - - if (showLogin) { - login(); - } else { - register(); - } - }; - - const handleHostInputChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setUrl(value); - application.setHost(value); - }; - - const handleKeyPressKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') { - handleAuthFormSubmit(event as TargetedKeyboardEvent); - } - }; - - const handlePasswordChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setPassword(value); - }; - - const handleEmailChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setEmail(value); - }; - - const handlePasswordConfirmationChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setPasswordConfirmation(value); - }; - - const handleMergeLocalData = async (event: TargetedEvent) => { - const { checked } = event.target as HTMLInputElement; - - if (!checked) { - setShouldMergeLocal(checked); - - const confirmResult = await confirmDialog({ - text: STRING_ACCOUNT_MENU_UNCHECK_MERGE, - confirmButtonStyle: 'danger' - }); - setShouldMergeLocal(!confirmResult); - } - }; - - const openPasswordWizard = () => { - closeAccountMenu(); - application.presentPasswordWizard(PasswordWizardType.ChangePassword); - }; - - const openSessionsModal = () => { - closeAccountMenu(); - appState.openSessionsModal(); - }; - - const getEncryptionStatusForNotes = () => { - const length = notesAndTagsCount; - return `${length}/${length} notes and tags encrypted`; - }; - - const enableProtections = () => { - application.clearProtectionSession(); - }; - - const handleAddPassCode = () => { - setShowPasscodeForm(true); - setIsPasscodeFocused(true); - }; - - const submitPasscodeForm = async (event: TargetedEvent | TargetedMouseEvent) => { - 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(); - }; - - const handlePasscodeChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setPasscode(value); - }; - - const handleConfirmPasscodeChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setPasscodeConfirmation(value); - }; - - const selectAutoLockInterval = async (interval: number) => { - if (!(await application.authorizeAutolockIntervalChange())) { - return; - } - await application.getAutolockService().setAutoLockInterval(interval); - reloadAutoLockInterval(); - }; - - const disableBetaWarning = () => { - appState.disableBetaWarning(); - }; - - const hidePasswordForm = () => { - setShowLogin(false); - setShowRegister(false); - setPassword(''); - setPasswordConfirmation(undefined); - }; - - const signOut = () => { - appState.accountMenu.setSigningOut(true); - }; - - const changePasscodePressed = () => { - handleAddPassCode(); - }; - - 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 downloadDataArchive = () => { - application.getArchiveService().downloadBackup(isBackupEncrypted); - }; - - - const readFile = async (file: File): Promise => { - 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) => { - 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 }); - } - }; - - 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 Bugsnag - to automatically report errors that occur while the app is running. See - - this article, paragraph 'Browser' under 'Sending diagnostic data', - - to see what data is included in error reports. -

- 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. - ` - }); - }; - - // Add the required event observers - useEffect(() => { - const removeAppLaunchedObserver = application.addEventObserver( - async () => { - refreshCredentialState(); - loadHost(); - reloadAutoLockInterval(); - refreshEncryptionStatus(); - }, - ApplicationEvent.Launched - ); - - const removeKeyStatusChangedObserver = application.addEventObserver( - async () => { - refreshCredentialState(); - }, - ApplicationEvent.KeyStatusChanged - ); - - const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver( - async () => { - setProtectionsDisabledUntil(getProtectionsDisabledUntil()); - }, - ApplicationEvent.ProtectionSessionExpiryDateChanged - ); - - return () => { - removeAppLaunchedObserver(); - removeKeyStatusChangedObserver(); - removeProtectionSessionExpiryDateChangedObserver(); - }; - }, []); - - // `reloadAutoLockInterval` gets interval asynchronously, therefore we call `useEffect` to set initial - // value of `selectedAutoLockInterval` - useEffect(() => { - reloadAutoLockInterval(); - }, [reloadAutoLockInterval]); - - useEffect(() => { - refreshEncryptionStatus(); - }, [refreshEncryptionStatus]); - - useEffect(() => { - if (isEmailFocused) { - emailInputRef.current.focus(); - setIsEmailFocused(false); - } - if (isPasscodeFocused) { - passcodeInputRef.current.focus(); - setIsPasscodeFocused(false); - } - }, [isEmailFocused, isPasscodeFocused]); - - return ( -
-
-
-
Account
- Close -
-
- {!user && !showLogin && !showRegister && ( -
-
-
Sign in or register to enable sync and end-to-end encryption.
-
-
- - -
-
- Standard Notes is free on every platform, and comes - standard with sync and encryption. -
-
- )} - {(showLogin || showRegister) && ( -
-
- {showLogin ? 'Sign In' : 'Register'} -
-
-
- - - {showRegister && - } - - {showAdvanced && ( -
-
-
- Advanced Options -
-
- - -
- {showLogin && ( - - )} -
-
- )} - {!isAuthenticating && ( -
- -
- )} - {showRegister && ( -
-
No Password Reset.
-
- Because your notes are encrypted using your password, - Standard Notes does not have a password reset option. - You cannot forget your password. -
-
- )} - {status && ( -
-
-
-
{status}
-
-
- )} - {!isAuthenticating && ( -
- - {notesAndTagsCount > 0 && ( - - )} -
- )} - -
- )} - {!showLogin && !showRegister && ( -
- {user && ( -
- {appState.sync.errorMessage && ( -
-
Sync Unreachable
-
- Hmm...we can't seem to sync your account. - The reason: {appState.sync.errorMessage} -
- - Need help? - -
- )} -
-
-
- {user.email} -
-
- {server} -
-
-
- - )} - -
-
- Encryption -
- {isEncryptionEnabled && ( -
- {getEncryptionStatusForNotes()} -
- )} -

- {encryptionStatusString} -

-
- {hasProtections && ( -
-
Protections
- {protectionsDisabledUntil && ( -
- Protections are disabled until {protectionsDisabledUntil} -
- )} - {!protectionsDisabledUntil && ( -
- Protections are enabled -
- )} -

- Actions like viewing protected notes, exporting decrypted backups, - or revoking an active session, require additional authentication - like entering your account password or application passcode. -

- {protectionsDisabledUntil && ( -
- -
- )} -
- )} -
-
Passcode Lock
- {!hasPasscode && ( -
- {canAddPasscode && ( - <> - {!showPasscodeForm && ( -
- -
- )} -

- Add a passcode to lock the application and - encrypt on-device key storage. -

- {keyStorageInfo && ( -

{keyStorageInfo}

- )} - - )} - {!canAddPasscode && ( -

- Adding a passcode is not supported in temporary sessions. Please sign - out, then sign back in with the "Stay signed in" option checked. -

- )} -
- )} - {showPasscodeForm && ( -
-
- - - - - - )} - {hasPasscode && !showPasscodeForm && ( - <> -
Passcode lock is enabled
-
-
Options
-
-
-
-
Autolock
- {passcodeAutoLockOptions.map(option => { - return ( - selectAutoLockInterval(option.value)}> - {option.label} - - ); - })} -
-
-
The autolock timer begins when the window or tab loses focus.
- -
- - )} -
- {isImportDataLoading ? ( -
- ) : ( -
-
Data Backups
-
Download a backup of all your data.
- {isEncryptionEnabled && ( -
-
- - -
-
- )} -
-
- - -
- {isDesktopApplication() && ( -

- Backups are automatically created on desktop and can be managed - via the "Backups" top-level menu. -

- )} -
-
- )} -
-
Error Reporting
-
- Automatic error reporting is {isErrorReportingEnabled ? 'enabled' : 'disabled'} -
-

- Help us improve Standard Notes by automatically submitting - anonymized error reports. -

- {errorReportingIdValue && ( - <> -

- Your random identifier is - strong {errorReportingIdValue} -

-

- 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. -

- - )} -
- -
- -
-
- )} -
- -
-
-
- {appVersion} - {showBetaWarning && ( - - ( - Hide beta warning - ) - - )} -
- {(showLogin || showRegister) && ( - Cancel - )} - {!showLogin && !showRegister && ( - - {user ? 'Sign out' : 'Clear session data'} - - )} -
-
-
-
- ); -}); - -export const AccountMenuDirective = toDirective( - AccountMenu -); diff --git a/app/assets/javascripts/components/AccountMenu/Authentication.tsx b/app/assets/javascripts/components/AccountMenu/Authentication.tsx new file mode 100644 index 000000000..01c4df13b --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/Authentication.tsx @@ -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(''); + const [status, setStatus] = useState(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) => { + const { value } = event.target as HTMLInputElement; + setServer(value); + application.setHost(value); + }; + + const emailInputRef = useRef(); + const passwordInputRef = useRef(); + const passwordConfirmationInputRef = useRef(); + + 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 | + TargetedMouseEvent | + TargetedKeyboardEvent + ) => { + event.preventDefault(); + + if (!email || !password) { + return; + } + + blurAuthFields(); + + if (showLogin) { + login(); + } else { + register(); + } + }; + + const handleKeyPressKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + handleAuthFormSubmit(event as TargetedKeyboardEvent); + } + }; + + const handlePasswordChange = (event: TargetedEvent) => { + const { value } = event.target as HTMLInputElement; + setPassword(value); + }; + + const handleEmailChange = (event: TargetedEvent) => { + const { value } = event.target as HTMLInputElement; + setEmail(value); + }; + + const handlePasswordConfirmationChange = (event: TargetedEvent) => { + const { value } = event.target as HTMLInputElement; + setPasswordConfirmation(value); + }; + + const handleMergeLocalData = async (event: TargetedEvent) => { + 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 && ( +
+
+
Sign in or register to enable sync and end-to-end encryption.
+
+
+ + +
+
+ Standard Notes is free on every platform, and comes + standard with sync and encryption. +
+
+ )} + {(showLogin || showRegister) && ( +
+
+ {showLogin ? 'Sign In' : 'Register'} +
+
+
+ + + {showRegister && + } +
+ +
+ {showAdvanced && ( +
+
+
+ Advanced Options +
+
+ + +
+ {showLogin && ( + + )} +
+
+ )} + {!isAuthenticating && ( +
+ +
+ )} + {showRegister && ( +
+
No Password Reset.
+
+ Because your notes are encrypted using your password, + Standard Notes does not have a password reset option. + You cannot forget your password. +
+
+ )} + {status && ( +
+
+
+
{status}
+
+
+ )} + {!isAuthenticating && ( +
+ + {notesAndTagsCount > 0 && ( + + )} +
+ )} + +
+ )} + ); +}); + +export default Authentication; diff --git a/app/assets/javascripts/components/AccountMenu/DataBackup.tsx b/app/assets/javascripts/components/AccountMenu/DataBackup.tsx new file mode 100644 index 000000000..e7bff8a1a --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/DataBackup.tsx @@ -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(null); + const [isImportDataLoading, setIsImportDataLoading] = useState(false); + + const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu; + + const downloadDataArchive = () => { + application.getArchiveService().downloadBackup(isBackupEncrypted); + }; + + const readFile = async (file: File): Promise => { + 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) => { + 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 | 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 ? ( +
+ ) : ( +
+
Data Backups
+
Download a backup of all your data.
+ {isEncryptionEnabled && ( +
+
+ + +
+
+ )} +
+
+ + +
+ {isDesktopApplication() && ( +

+ Backups are automatically created on desktop and can be managed + via the "Backups" top-level menu. +

+ )} +
+
+ )} + + ); +}); + +export default DataBackup; diff --git a/app/assets/javascripts/components/AccountMenu/Encryption.tsx b/app/assets/javascripts/components/AccountMenu/Encryption.tsx new file mode 100644 index 000000000..1a98f2404 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/Encryption.tsx @@ -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 ( +
+
+ Encryption +
+ {isEncryptionEnabled && ( +
+ {getEncryptionStatusForNotes()} +
+ )} +

+ {encryptionStatusString} +

+
+ ); +}); + +export default Encryption; diff --git a/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx b/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx new file mode 100644 index 000000000..cfb616af6 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx @@ -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 Bugsnag + to automatically report errors that occur while the app is running. See + + this article, paragraph 'Browser' under 'Sending diagnostic data', + + to see what data is included in error reports. +

+ 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 ( +
+
Error Reporting
+
+ Automatic error reporting is {isErrorReportingEnabled ? 'enabled' : 'disabled'} +
+

+ Help us improve Standard Notes by automatically submitting + anonymized error reports. +

+ {errorReportingIdValue && ( + <> +

+ Your random identifier is + strong {errorReportingIdValue} +

+

+ 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. +

+ + )} +
+ +
+ +
+ ); +}); + +export default ErrorReporting; diff --git a/app/assets/javascripts/components/AccountMenu/Footer.tsx b/app/assets/javascripts/components/AccountMenu/Footer.tsx new file mode 100644 index 000000000..50f67f5d0 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/Footer.tsx @@ -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 ( +
+
+
+ {appVersion} + {showBetaWarning && ( + + ( + Hide beta warning + ) + + )} +
+ {(showLogin || showRegister) && ( + Cancel + )} + {!showLogin && !showRegister && ( + + {user ? 'Sign out' : 'Clear session data'} + + )} +
+
+ ); +}); + +export default Footer; diff --git a/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx b/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx new file mode 100644 index 000000000..579dc740e --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx @@ -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(); + + const [passcode, setPasscode] = useState(undefined); + const [passcodeConfirmation, setPasscodeConfirmation] = useState(undefined); + const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState(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) => { + const { value } = event.target as HTMLInputElement; + setPasscode(value); + }; + + const handleConfirmPasscodeChange = (event: TargetedEvent) => { + const { value } = event.target as HTMLInputElement; + setPasscodeConfirmation(value); + }; + + const submitPasscodeForm = async (event: TargetedEvent | TargetedMouseEvent) => { + 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 ( +
+
Passcode Lock
+ {!hasPasscode && ( +
+ {canAddPasscode && ( + <> + {!showPasscodeForm && ( +
+ +
+ )} +

+ Add a passcode to lock the application and + encrypt on-device key storage. +

+ {keyStorageInfo && ( +

{keyStorageInfo}

+ )} + + )} + {!canAddPasscode && ( +

+ Adding a passcode is not supported in temporary sessions. Please sign + out, then sign back in with the "Stay signed in" option checked. +

+ )} +
+ )} + {showPasscodeForm && ( +
+
+ + + + + + )} + {hasPasscode && !showPasscodeForm && ( + <> +
Passcode lock is enabled
+
+
Options
+
+
+
+
Autolock
+ {passcodeAutoLockOptions.map(option => { + return ( + selectAutoLockInterval(option.value)}> + {option.label} + + ); + })} +
+
+
The autolock timer begins when the window or tab loses focus.
+ +
+ + )} +
+ ); +}); + +export default PasscodeLock; diff --git a/app/assets/javascripts/components/AccountMenu/Protections.tsx b/app/assets/javascripts/components/AccountMenu/Protections.tsx new file mode 100644 index 000000000..8e7b1f229 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/Protections.tsx @@ -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 = ({ 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 ( +
+
Protections
+ {protectionsDisabledUntil && ( +
+ Protections are disabled until {protectionsDisabledUntil} +
+ )} + {!protectionsDisabledUntil && ( +
+ Protections are enabled +
+ )} +

+ Actions like viewing protected notes, exporting decrypted backups, + or revoking an active session, require additional authentication + like entering your account password or application passcode. +

+ {protectionsDisabledUntil && ( +
+ +
+ )} +
+ ); +}; + +export default Protections; diff --git a/app/assets/javascripts/components/AccountMenu/User.tsx b/app/assets/javascripts/components/AccountMenu/User.tsx new file mode 100644 index 000000000..6ba3d71ab --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/User.tsx @@ -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 ( +
+ {appState.sync.errorMessage && ( +
+
Sync Unreachable
+
+ Hmm...we can't seem to sync your account. + The reason: {appState.sync.errorMessage} +
+ + Need help? + +
+ )} +
+
+
+ {(user as User).email} +
+
+ {server} +
+
+
+ + ); +}); + +export default User; diff --git a/app/assets/javascripts/components/AccountMenu/index.tsx b/app/assets/javascripts/components/AccountMenu/index.tsx new file mode 100644 index 000000000..b20713436 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/index.tsx @@ -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 ( +
+
+
+
Account
+ Close +
+
+ + {!showLogin && !showRegister && ( +
+ {user && ( + + )} + + + + + +
+ )} +
+ +
+
+
+ ); +}); + +export const AccountMenuDirective = toDirective( + AccountMenu +); diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index 78a874ab4..67c0aa592 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -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 = ({ type, className }) => { +export const Icon: FunctionalComponent = ({ type, className }) => { const IconComponent = ICONS[type]; return ; }; diff --git a/app/assets/javascripts/ui_models/app_state/account_menu_state.ts b/app/assets/javascripts/ui_models/app_state/account_menu_state.ts index 1c0a9acae..13f243dd1 100644 --- a/app/assets/javascripts/ui_models/app_state/account_menu_state.ts +++ b/app/assets/javascripts/ui_models/app_state/account_menu_state.ts @@ -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; }; diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 91330d101..fd98a276b 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -106,7 +106,7 @@ export class AppState { ); this.accountMenu = new AccountMenuState( application, - this.appEventObserverRemovers + this.appEventObserverRemovers, ); this.searchOptions = new SearchOptionsState( application, diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 1d6acad2c..4c898267e 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -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; diff --git a/app/assets/stylesheets/_ui.scss b/app/assets/stylesheets/_ui.scss index 3822e130e..e1f7a1dee 100644 --- a/app/assets/stylesheets/_ui.scss +++ b/app/assets/stylesheets/_ui.scss @@ -193,6 +193,10 @@ $screen-md-max: ($screen-lg-min - 1) !default; .cursor-pointer { cursor: pointer; + + input[type="checkbox"] { + cursor: pointer; + } } .fill-current { diff --git a/package.json b/package.json index cff3f77e3..dec3adcb4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/yarn.lock b/yarn.lock index 15369aab3..9b3858e4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"