refactor: repo (#1070)

This commit is contained in:
Mo
2022-06-07 07:18:41 -05:00
committed by GitHub
parent 4c65784421
commit f4ef63693c
1102 changed files with 5786 additions and 3366 deletions

View File

@@ -0,0 +1,65 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import Backups from '@/Components/Preferences/Panes/Backups/Backups'
import Appearance from './Panes/Appearance'
import General from './Panes/General/General'
import AccountPreferences from './Panes/Account/AccountPreferences'
import Security from './Panes/Security/Security'
import Listed from './Panes/Listed/Listed'
import HelpAndFeedback from './Panes/HelpFeedback'
import { PreferencesProps } from './PreferencesProps'
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = ({
menu,
viewControllerManager,
application,
mfaProvider,
userProvider,
}) => {
switch (menu.selectedPaneId) {
case 'general':
return (
<General
viewControllerManager={viewControllerManager}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
)
case 'account':
return <AccountPreferences application={application} viewControllerManager={viewControllerManager} />
case 'appearance':
return <Appearance application={application} />
case 'security':
return (
<Security
mfaProvider={mfaProvider}
userProvider={userProvider}
viewControllerManager={viewControllerManager}
application={application}
/>
)
case 'backups':
return <Backups application={application} viewControllerManager={viewControllerManager} />
case 'listed':
return <Listed application={application} />
case 'shortcuts':
return null
case 'accessibility':
return null
case 'get-free-month':
return null
case 'help-feedback':
return <HelpAndFeedback />
default:
return (
<General
viewControllerManager={viewControllerManager}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
)
}
}
export default observer(PaneSelector)

View File

@@ -0,0 +1,35 @@
import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import Authentication from './Authentication'
import Credentials from './Credentials'
import Sync from './Sync'
import Subscription from './Subscription/Subscription'
import SignOutWrapper from './SignOutView'
import FilesSection from './Files'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const AccountPreferences = ({ application, viewControllerManager }: Props) => (
<PreferencesPane>
{!application.hasAccount() ? (
<Authentication application={application} viewControllerManager={viewControllerManager} />
) : (
<>
<Credentials application={application} viewControllerManager={viewControllerManager} />
<Sync application={application} />
</>
)}
<Subscription application={application} viewControllerManager={viewControllerManager} />
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
<FilesSection application={application} />
)}
<SignOutWrapper application={application} viewControllerManager={viewControllerManager} />
</PreferencesPane>
)
export default observer(AccountPreferences)

View File

@@ -0,0 +1,39 @@
import { FunctionComponent } from 'react'
import OfflineSubscription from '@/Components/Preferences/Panes/Account/OfflineSubscription'
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import Extensions from '@/Components/Preferences/Panes/Extensions/Extensions'
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
import AccordionItem from '@/Components/Shared/AccordionItem'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
extensionsLatestVersions: ExtensionsLatestVersions
}
const Advanced: FunctionComponent<Props> = ({ application, viewControllerManager, extensionsLatestVersions }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Advanced Settings'}>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<OfflineSubscription application={application} viewControllerManager={viewControllerManager} />
<Extensions
className={'mt-3'}
application={application}
extensionsLatestVersions={extensionsLatestVersions}
/>
</div>
</div>
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Advanced)

View File

@@ -0,0 +1,52 @@
import Button from '@/Components/Button/Button'
import { Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { AccountIllustration } from '@standardnotes/icons'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const Authentication: FunctionComponent<Props> = ({ viewControllerManager }) => {
const clickSignIn = () => {
viewControllerManager.preferencesController.closePreferences()
viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.SignIn)
viewControllerManager.accountMenuController.setShow(true)
}
const clickRegister = () => {
viewControllerManager.preferencesController.closePreferences()
viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.Register)
viewControllerManager.accountMenuController.setShow(true)
}
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-col items-center px-12">
<AccountIllustration className="mb-3" />
<Title>You're not signed in</Title>
<Text className="text-center mb-3">
Sign in to sync your notes and preferences across all your devices and enable end-to-end encryption.
</Text>
<Button variant="primary" label="Create free account" onClick={clickRegister} className="mb-3" />
<div className="text-input">
Already have an account?{' '}
<button className="border-0 p-0 bg-default color-info underline cursor-pointer" onClick={clickSignIn}>
Sign in
</button>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Authentication)

View File

@@ -0,0 +1,159 @@
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import { FunctionComponent, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { useBeforeUnload } from '@/Hooks/useBeforeUnload'
import ChangeEmailForm from './ChangeEmailForm'
import ChangeEmailSuccess from './ChangeEmailSuccess'
import { isEmailValid } from '@/Utils'
enum SubmitButtonTitles {
Default = 'Continue',
GeneratingKeys = 'Generating Keys...',
Finish = 'Finish',
}
enum Steps {
InitialStep,
FinishStep,
}
type Props = {
onCloseDialog: () => void
application: WebApplication
}
const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) => {
const [currentPassword, setCurrentPassword] = useState('')
const [newEmail, setNewEmail] = useState('')
const [isContinuing, setIsContinuing] = useState(false)
const [lockContinue, setLockContinue] = useState(false)
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default)
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
useBeforeUnload()
const applicationAlertService = application.alertService
const validateCurrentPassword = async () => {
if (!currentPassword || currentPassword.length === 0) {
applicationAlertService.alert('Please enter your current password.').catch(console.error)
return false
}
const success = await application.validateAccountPassword(currentPassword)
if (!success) {
applicationAlertService
.alert('The current password you entered is not correct. Please try again.')
.catch(console.error)
return false
}
return success
}
const validateNewEmail = async () => {
if (!isEmailValid(newEmail)) {
applicationAlertService
.alert('The email you entered has an invalid format. Please review your input and try again.')
.catch(console.error)
return false
}
return true
}
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default)
setIsContinuing(false)
}
const processEmailChange = async () => {
await application.downloadBackup()
setLockContinue(true)
const response = await application.changeEmail(newEmail, currentPassword)
const success = !response.error
setLockContinue(false)
return success
}
const dismiss = () => {
if (lockContinue) {
applicationAlertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
onCloseDialog()
}
}
const handleSubmit = async () => {
if (lockContinue || isContinuing) {
return
}
if (currentStep === Steps.FinishStep) {
dismiss()
return
}
setIsContinuing(true)
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys)
const valid = (await validateCurrentPassword()) && (await validateNewEmail())
if (!valid) {
resetProgressState()
return
}
const success = await processEmailChange()
if (!success) {
resetProgressState()
return
}
setIsContinuing(false)
setSubmitButtonTitle(SubmitButtonTitles.Finish)
setCurrentStep(Steps.FinishStep)
}
const handleDialogClose = () => {
if (lockContinue) {
applicationAlertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
onCloseDialog()
}
}
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>Change Email</ModalDialogLabel>
<ModalDialogDescription className="px-4.5">
{currentStep === Steps.InitialStep && (
<ChangeEmailForm setNewEmail={setNewEmail} setCurrentPassword={setCurrentPassword} />
)}
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button className="min-w-20" variant="primary" label={submitButtonTitle} onClick={handleSubmit} />
</ModalDialogButtons>
</ModalDialog>
</div>
)
}
export default ChangeEmail

View File

@@ -0,0 +1,45 @@
import { Dispatch, SetStateAction, FunctionComponent } from 'react'
type Props = {
setNewEmail: Dispatch<SetStateAction<string>>
setCurrentPassword: Dispatch<SetStateAction<string>>
}
const labelClassName = 'block mb-1'
const inputClassName = 'sk-input contrast'
const ChangeEmailForm: FunctionComponent<Props> = ({ setNewEmail, setCurrentPassword }) => {
return (
<div className="w-full flex flex-col">
<div className="mt-2 mb-3">
<label className={labelClassName} htmlFor="change-email-email-input">
New Email:
</label>
<input
id="change-email-email-input"
className={inputClassName}
type="email"
onChange={({ target }) => {
setNewEmail((target as HTMLInputElement).value)
}}
/>
</div>
<div className="mb-2">
<label className={labelClassName} htmlFor="change-email-password-input">
Current Password:
</label>
<input
id="change-email-password-input"
className={inputClassName}
type="password"
onChange={({ target }) => {
setCurrentPassword((target as HTMLInputElement).value)
}}
/>
</div>
</div>
)
}
export default ChangeEmailForm

View File

@@ -0,0 +1,15 @@
import { FunctionComponent } from 'react'
const ChangeEmailSuccess: FunctionComponent = () => {
return (
<div>
<div className={'sk-label sk-bold info mt-2'}>Your email has been successfully changed.</div>
<p className={'sk-p'}>
Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum
compatibility.
</p>
</div>
)
}
export default ChangeEmailSuccess

View File

@@ -0,0 +1,30 @@
import Button from '@/Components/Button/Button'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { Title, Text } from '../../PreferencesComponents/Content'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
const ClearSessionDataView: FunctionComponent<{
viewControllerManager: ViewControllerManager
}> = ({ viewControllerManager }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Clear workspace</Title>
<Text>Remove all data related to the current workspace from the application.</Text>
<div className="min-h-3" />
<Button
dangerStyle={true}
label="Clear workspace"
onClick={() => {
viewControllerManager.accountMenuController.setSigningOut(true)
}}
/>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(ClearSessionDataView)

View File

@@ -0,0 +1,71 @@
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import { dateToLocalizedString } from '@standardnotes/snjs'
import { useCallback, useState, FunctionComponent } from 'react'
import ChangeEmail from '@/Components/Preferences/Panes/Account/ChangeEmail/ChangeEmail'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import PasswordWizard from '@/Components/PasswordWizard/PasswordWizard'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const Credentials: FunctionComponent<Props> = ({ application }: Props) => {
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false)
const [shouldShowPasswordWizard, setShouldShowPasswordWizard] = useState(false)
const user = application.getUser()
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp)
const presentPasswordWizard = useCallback(() => {
setShouldShowPasswordWizard(true)
}, [])
const dismissPasswordWizard = useCallback(() => {
setShouldShowPasswordWizard(false)
}, [])
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Credentials</Title>
<Subtitle>Email</Subtitle>
<Text>
You're signed in as <span className="font-bold wrap">{user?.email}</span>
</Text>
<Button
className="min-w-20 mt-3"
variant="normal"
label="Change email"
onClick={() => {
setIsChangeEmailDialogOpen(true)
}}
/>
<HorizontalSeparator classes="my-4" />
<Subtitle>Password</Subtitle>
<Text>
Current password was set on <span className="font-bold">{passwordCreatedOn}</span>
</Text>
<Button className="min-w-20 mt-3" variant="normal" label="Change password" onClick={presentPasswordWizard} />
{isChangeEmailDialogOpen && (
<ChangeEmail onCloseDialog={() => setIsChangeEmailDialogOpen(false)} application={application} />
)}
</PreferencesSegment>
</PreferencesGroup>
{shouldShowPasswordWizard ? (
<PasswordWizard application={application} dismissModal={dismissPasswordWizard} />
) : null}
</>
)
}
export default observer(Credentials)

View File

@@ -0,0 +1,68 @@
import { WebApplication } from '@/Application/Application'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { SubscriptionSettingName } from '@standardnotes/snjs'
import { FunctionComponent, useEffect, useState } from 'react'
import { Subtitle, Title } from '../../PreferencesComponents/Content'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const FilesSection: FunctionComponent<Props> = ({ application }) => {
const [isLoading, setIsLoading] = useState(true)
const [filesQuotaUsed, setFilesQuotaUsed] = useState<number>(0)
const [filesQuotaTotal, setFilesQuotaTotal] = useState<number>(0)
useEffect(() => {
const getFilesQuota = async () => {
const filesQuotaUsed = await application.settings.getSubscriptionSetting(
SubscriptionSettingName.FileUploadBytesUsed,
)
const filesQuotaTotal = await application.settings.getSubscriptionSetting(
SubscriptionSettingName.FileUploadBytesLimit,
)
if (filesQuotaUsed) {
setFilesQuotaUsed(parseFloat(filesQuotaUsed))
}
if (filesQuotaTotal) {
setFilesQuotaTotal(parseFloat(filesQuotaTotal))
}
setIsLoading(false)
}
getFilesQuota().catch(console.error)
}, [application])
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Files</Title>
<Subtitle>Storage Quota</Subtitle>
{isLoading ? (
<div className="mt-2">
<div className="sk-spinner spinner-info w-3 h-3"></div>
</div>
) : (
<>
<div className="mt-1 mb-1">
<span className="font-semibold">{formatSizeToReadableString(filesQuotaUsed)}</span> of{' '}
<span>{formatSizeToReadableString(filesQuotaTotal)}</span> used
</div>
<progress
className="w-full progress-bar"
aria-label="Files storage used"
value={filesQuotaUsed}
max={filesQuotaTotal}
/>
</>
)}
</PreferencesSegment>
</PreferencesGroup>
)
}
export default FilesSection

View File

@@ -0,0 +1,127 @@
import React, { FunctionComponent, useEffect, useState } from 'react'
import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/Constants/Strings'
import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const OfflineSubscription: FunctionComponent<Props> = ({ application }) => {
const [activationCode, setActivationCode] = useState('')
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false)
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false)
const [hasUserPreviouslyStoredCode, setHasUserPreviouslyStoredCode] = useState(false)
useEffect(() => {
if (application.features.hasOfflineRepo()) {
setHasUserPreviouslyStoredCode(true)
}
}, [application])
const shouldShowOfflineSubscription = () => {
return !application.hasAccount() || application.isThirdPartyHostUsed() || hasUserPreviouslyStoredCode
}
const handleSubscriptionCodeSubmit = async (event: React.FormEvent) => {
event.preventDefault()
const result = await application.features.setOfflineFeaturesCode(activationCode)
if (result instanceof ClientDisplayableError) {
await application.alertService.alert(result.text)
} else {
setIsSuccessfullyActivated(true)
setHasUserPreviouslyStoredCode(true)
setIsSuccessfullyRemoved(false)
}
}
const handleRemoveOfflineKey = async () => {
await application.features.deleteOfflineFeatureRepo()
setIsSuccessfullyActivated(false)
setHasUserPreviouslyStoredCode(false)
setActivationCode('')
setIsSuccessfullyRemoved(true)
}
const handleRemoveClick = async () => {
application.alertService
.confirm(
STRING_REMOVE_OFFLINE_KEY_CONFIRMATION,
'Remove offline key?',
'Remove Offline Key',
ButtonType.Danger,
'Cancel',
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await handleRemoveOfflineKey()
}
})
.catch((err: string) => {
application.alertService.alert(err).catch(console.error)
})
}
if (!shouldShowOfflineSubscription()) {
return null
}
return (
<>
<div className="flex items-center justify-between">
<div className="flex flex-col mt-3 w-full">
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
<form onSubmit={handleSubscriptionCodeSubmit}>
<div className={'mt-2'}>
{!hasUserPreviouslyStoredCode && (
<DecoratedInput
onChange={(code) => setActivationCode(code)}
placeholder={'Offline Subscription Code'}
value={activationCode}
disabled={isSuccessfullyActivated}
className={'mb-3'}
/>
)}
</div>
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
<div className={'mt-3 mb-3 info'}>
Your offline subscription code has been successfully {isSuccessfullyActivated ? 'activated' : 'removed'}
.
</div>
)}
{hasUserPreviouslyStoredCode && (
<Button
dangerStyle={true}
label="Remove offline key"
onClick={() => {
handleRemoveClick().catch(console.error)
}}
/>
)}
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
<Button
label={'Submit'}
variant="primary"
disabled={activationCode === ''}
onClick={(event) => handleSubscriptionCodeSubmit(event)}
/>
)}
</form>
</div>
</div>
<HorizontalSeparator classes="mt-8 mb-5" />
</>
)
}
export default observer(OfflineSubscription)

View File

@@ -0,0 +1,71 @@
import Button from '@/Components/Button/Button'
import OtherSessionsSignOutContainer from '@/Components/OtherSessionsSignOut/OtherSessionsSignOut'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { Subtitle, Title, Text } from '../../PreferencesComponents/Content'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import ClearSessionDataView from './ClearSessionDataView'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const SignOutView: FunctionComponent<Props> = observer(({ application, viewControllerManager }) => {
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Sign out</Title>
<Subtitle>Other devices</Subtitle>
<Text>Want to sign out on all devices except this one?</Text>
<div className="min-h-3" />
<div className="flex flex-row">
<Button
className="mr-3"
variant="normal"
label="Sign out other sessions"
onClick={() => {
viewControllerManager.accountMenuController.setOtherSessionsSignOut(true)
}}
/>
<Button
variant="normal"
label="Manage sessions"
onClick={() => viewControllerManager.openSessionsModal()}
/>
</div>
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle>This workspace</Subtitle>
<Text>Remove all data related to the current workspace from the application.</Text>
<div className="min-h-3" />
<Button
dangerStyle={true}
label="Sign out workspace"
onClick={() => {
viewControllerManager.accountMenuController.setSigningOut(true)
}}
/>
</PreferencesSegment>
</PreferencesGroup>
<OtherSessionsSignOutContainer viewControllerManager={viewControllerManager} application={application} />
</>
)
})
SignOutView.displayName = 'SignOutView'
const SignOutWrapper: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
if (!application.hasAccount()) {
return <ClearSessionDataView viewControllerManager={viewControllerManager} />
}
return <SignOutView viewControllerManager={viewControllerManager} application={application} />
}
export default observer(SignOutWrapper)

View File

@@ -0,0 +1,44 @@
import { FunctionComponent, useState } from 'react'
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions'
type Props = {
application: WebApplication
}
const NoSubscription: FunctionComponent<Props> = ({ application }) => {
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
const onPurchaseClick = async () => {
const errorMessage = 'There was an error when attempting to redirect you to the subscription page.'
setIsLoadingPurchaseFlow(true)
try {
if (!(await loadPurchaseFlowUrl(application))) {
setPurchaseFlowError(errorMessage)
}
} catch (e) {
setPurchaseFlowError(errorMessage)
} finally {
setIsLoadingPurchaseFlow(false)
}
}
return (
<>
<Text>You don't have a Standard Notes subscription yet.</Text>
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
{purchaseFlowError && <Text className="color-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton className="min-w-20 mt-3 mr-3" label="Learn More" link={window.plansUrl as string} />
{application.hasAccount() && (
<Button className="min-w-20 mt-3" variant="primary" label="Subscribe" onClick={onPurchaseClick} />
)}
</div>
</>
)
}
export default NoSubscription

View File

@@ -0,0 +1,61 @@
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
import { observer } from 'mobx-react-lite'
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
type Props = { subscriptionState: SubscriptionController }
const StatusText = ({ subscriptionState }: Props) => {
const {
userSubscriptionName,
userSubscriptionExpirationDate,
isUserSubscriptionExpired,
isUserSubscriptionCanceled,
} = subscriptionState
const expirationDateString = userSubscriptionExpirationDate?.toLocaleString()
if (isUserSubscriptionCanceled) {
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription has been canceled{' '}
{isUserSubscriptionExpired ? (
<span className="font-bold">and expired on {expirationDateString}</span>
) : (
<span className="font-bold">but will remain valid until {expirationDateString}</span>
)}
. You may resubscribe below if you wish.
</Text>
)
}
if (isUserSubscriptionExpired) {
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription <span className="font-bold">expired on {expirationDateString}</span>. You may resubscribe below if
you wish.
</Text>
)
}
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription will be <span className="font-bold">renewed on {expirationDateString}</span>.
</Text>
)
}
export default observer(StatusText)

View File

@@ -0,0 +1,40 @@
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import SubscriptionInformation from './SubscriptionInformation'
import NoSubscription from './NoSubscription'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const Subscription: FunctionComponent<Props> = ({ application, viewControllerManager }: Props) => {
const subscriptionState = viewControllerManager.subscriptionController
const { userSubscription } = subscriptionState
const now = new Date().getTime()
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Subscription</Title>
{userSubscription && userSubscription.endsAt > now ? (
<SubscriptionInformation subscriptionState={subscriptionState} application={application} />
) : (
<NoSubscription application={application} />
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Subscription)

View File

@@ -0,0 +1,31 @@
import { observer } from 'mobx-react-lite'
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
import StatusText from './StatusText'
type Props = {
subscriptionState: SubscriptionController
application: WebApplication
}
const SubscriptionInformation = ({ subscriptionState, application }: Props) => {
const manageSubscription = async () => {
openSubscriptionDashboard(application)
}
return (
<>
<StatusText subscriptionState={subscriptionState} />
<Button
className="min-w-20 mt-3 mr-3"
variant="normal"
label="Manage subscription"
onClick={manageSubscription}
/>
</>
)
}
export default observer(SubscriptionInformation)

View File

@@ -0,0 +1,58 @@
import { Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import { SyncQueueStrategy } from '@standardnotes/snjs'
import { STRING_GENERIC_SYNC_ERROR } from '@/Constants/Strings'
import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useState } from 'react'
import { formatLastSyncDate } from '@/Utils/FormatLastSyncDate'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const Sync: FunctionComponent<Props> = ({ application }: Props) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
const doSynchronization = async () => {
setIsSyncingInProgress(true)
const response = await application.sync.sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true,
})
setIsSyncingInProgress(false)
if (response && (response as any).error) {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
} else {
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
}
}
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Sync</Title>
<Text>
Last synced <span className="font-bold">on {lastSyncDate}</span>
</Text>
<Button
className="min-w-20 mt-3"
variant="normal"
label="Sync now"
disabled={isSyncingInProgress}
onClick={doSynchronization}
/>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Sync)

View File

@@ -0,0 +1,156 @@
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Switch from '@/Components/Switch/Switch'
import { WebApplication } from '@/Application/Application'
import { ContentType, FeatureIdentifier, FeatureStatus, PrefKey, GetFeatures, SNTheme } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useEffect, useState } from 'react'
import { Subtitle, Title, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { sortThemes } from '@/Utils/SortThemes'
import PreferencesPane from '../PreferencesComponents/PreferencesPane'
import PreferencesGroup from '../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const Appearance: FunctionComponent<Props> = ({ application }) => {
const premiumModal = usePremiumModal()
const isEntitledToMidnightTheme =
application.features.getFeatureStatus(FeatureIdentifier.MidnightTheme) === FeatureStatus.Entitled
const [themeItems, setThemeItems] = useState<DropdownItem[]>([])
const [autoLightTheme, setAutoLightTheme] = useState<string>(
() => application.getPreference(PrefKey.AutoLightThemeIdentifier, 'Default') as string,
)
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoDarkThemeIdentifier,
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default',
) as string,
)
const [useDeviceSettings, setUseDeviceSettings] = useState(
() => application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean,
)
useEffect(() => {
const themesAsItems: DropdownItem[] = application.items
.getDisplayableComponents()
.filter((component) => component.isTheme())
.filter((component) => !(component as SNTheme).isLayerable())
.sort(sortThemes)
.map((theme) => {
return {
label: theme.name,
value: theme.identifier as string,
}
})
GetFeatures()
.filter((feature) => feature.content_type === ContentType.Theme && !feature.layerable)
.forEach((theme) => {
if (themesAsItems.findIndex((item) => item.value === theme.identifier) === -1) {
themesAsItems.push({
label: theme.name as string,
value: theme.identifier,
icon: 'premium-feature',
})
}
})
themesAsItems.unshift({
label: 'Default',
value: 'Default',
})
setThemeItems(themesAsItems)
}, [application])
const toggleUseDeviceSettings = () => {
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings).catch(console.error)
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
application
.setPreference(PrefKey.AutoLightThemeIdentifier, autoLightTheme as FeatureIdentifier)
.catch(console.error)
}
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
application
.setPreference(PrefKey.AutoDarkThemeIdentifier, autoDarkTheme as FeatureIdentifier)
.catch(console.error)
}
setUseDeviceSettings(!useDeviceSettings)
}
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`)
} else {
application.setPreference(PrefKey.AutoLightThemeIdentifier, value as FeatureIdentifier).catch(console.error)
setAutoLightTheme(value)
}
}
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`)
} else {
application.setPreference(PrefKey.AutoDarkThemeIdentifier, value as FeatureIdentifier).catch(console.error)
setAutoDarkTheme(value)
}
}
return (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Themes</Title>
<div className="mt-2">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Use system color scheme</Subtitle>
<Text>Automatically change active theme based on your system settings.</Text>
</div>
<Switch onChange={toggleUseDeviceSettings} checked={useDeviceSettings} />
</div>
<HorizontalSeparator classes="my-4" />
<div>
<Subtitle>Automatic Light Theme</Subtitle>
<Text>Theme to be used for system light mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-light-theme-dropdown"
label="Select the automatic light theme"
items={themeItems}
value={autoLightTheme}
onChange={changeAutoLightTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
<HorizontalSeparator classes="my-4" />
<div>
<Subtitle>Automatic Dark Theme</Subtitle>
<Text>Theme to be used for system dark mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-dark-theme-dropdown"
label="Select the automatic dark theme"
items={themeItems}
value={autoDarkTheme}
onChange={changeAutoDarkTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
)
}
export default observer(Appearance)

View File

@@ -0,0 +1,27 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FunctionComponent } from 'react'
import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane'
import CloudLink from './CloudBackups/CloudBackups'
import DataBackups from './DataBackups'
import EmailBackups from './EmailBackups'
import FileBackupsCrossPlatform from './Files/FileBackupsCrossPlatform'
import { observer } from 'mobx-react-lite'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
}
const Backups: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
return (
<PreferencesPane>
<DataBackups application={application} viewControllerManager={viewControllerManager} />
<FileBackupsCrossPlatform application={application} />
<EmailBackups application={application} />
<CloudLink application={application} />
</PreferencesPane>
)
}
export default observer(Backups)

View File

@@ -0,0 +1,226 @@
import {
useCallback,
useEffect,
useState,
FunctionComponent,
KeyboardEventHandler,
ChangeEventHandler,
MouseEventHandler,
} from 'react'
import {
ButtonType,
SettingName,
CloudProvider,
DropboxBackupFrequency,
GoogleDriveBackupFrequency,
OneDriveBackupFrequency,
} from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import Button from '@/Components/Button/Button'
import { isDev, openInNewTab } from '@/Utils'
import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { KeyboardKey } from '@/Services/IOService'
type Props = {
application: WebApplication
providerName: CloudProvider
isEntitledToCloudBackups: boolean
}
const CloudBackupProvider: FunctionComponent<Props> = ({ application, providerName, isEntitledToCloudBackups }) => {
const [authBegan, setAuthBegan] = useState(false)
const [successfullyInstalled, setSuccessfullyInstalled] = useState(false)
const [backupFrequency, setBackupFrequency] = useState<string | undefined>(undefined)
const [confirmation, setConfirmation] = useState('')
const disable: MouseEventHandler = async (event) => {
event.stopPropagation()
try {
const shouldDisable = await application.alertService.confirm(
'Are you sure you want to disable this integration?',
'Disable?',
'Disable',
ButtonType.Danger,
'Cancel',
)
if (shouldDisable) {
await application.settings.deleteSetting(backupFrequencySettingName)
await application.settings.deleteSetting(backupTokenSettingName)
setBackupFrequency(undefined)
}
} catch (error) {
application.alertService.alert(error as string).catch(console.error)
}
}
const installIntegration: MouseEventHandler = (event) => {
if (!isEntitledToCloudBackups) {
return
}
event.stopPropagation()
const authUrl = application.getCloudProviderIntegrationUrl(providerName, isDev)
openInNewTab(authUrl)
setAuthBegan(true)
}
const performBackupNow = async () => {
// A backup is performed anytime the setting is updated with the integration token, so just update it here
try {
await application.settings.updateSetting(backupFrequencySettingName, backupFrequency as string)
void application.alertService.alert(
'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.',
)
} catch (err) {
application.alertService
.alert('There was an error while trying to trigger a backup for this provider. Please try again.')
.catch(console.error)
}
}
const backupSettingsData = {
[CloudProvider.Dropbox]: {
backupTokenSettingName: SettingName.DropboxBackupToken,
backupFrequencySettingName: SettingName.DropboxBackupFrequency,
defaultBackupFrequency: DropboxBackupFrequency.Daily,
},
[CloudProvider.Google]: {
backupTokenSettingName: SettingName.GoogleDriveBackupToken,
backupFrequencySettingName: SettingName.GoogleDriveBackupFrequency,
defaultBackupFrequency: GoogleDriveBackupFrequency.Daily,
},
[CloudProvider.OneDrive]: {
backupTokenSettingName: SettingName.OneDriveBackupToken,
backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
defaultBackupFrequency: OneDriveBackupFrequency.Daily,
},
}
const { backupTokenSettingName, backupFrequencySettingName, defaultBackupFrequency } =
backupSettingsData[providerName]
const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
const urlSearchParams = new URLSearchParams(url.search)
let integrationTokenKeyInUrl = ''
switch (providerName) {
case CloudProvider.Dropbox:
integrationTokenKeyInUrl = 'dbt'
break
case CloudProvider.Google:
integrationTokenKeyInUrl = 'key'
break
case CloudProvider.OneDrive:
integrationTokenKeyInUrl = 'key'
break
default:
throw new Error('Invalid Cloud Provider name')
}
return urlSearchParams.get(integrationTokenKeyInUrl)
}
const handleKeyPress: KeyboardEventHandler = async (event) => {
if (event.key === KeyboardKey.Enter) {
try {
const decryptedCode = atob(confirmation)
const urlFromDecryptedCode = new URL(decryptedCode)
const cloudProviderToken = getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode)
if (!cloudProviderToken) {
throw new Error()
}
await application.settings.updateSetting(backupTokenSettingName, cloudProviderToken)
await application.settings.updateSetting(backupFrequencySettingName, defaultBackupFrequency)
setBackupFrequency(defaultBackupFrequency)
setAuthBegan(false)
setSuccessfullyInstalled(true)
setConfirmation('')
await application.alertService.alert(
`${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.`,
)
} catch (e) {
await application.alertService.alert('Invalid code. Please try again.')
}
}
}
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setConfirmation(event.target.value)
}
const getIntegrationStatus = useCallback(async () => {
if (!application.getUser()) {
return
}
const frequency = await application.settings.getSetting(backupFrequencySettingName)
setBackupFrequency(frequency)
}, [application, backupFrequencySettingName])
useEffect(() => {
getIntegrationStatus().catch(console.error)
}, [getIntegrationStatus])
const isExpanded = authBegan || successfullyInstalled
const shouldShowEnableButton = !backupFrequency && !authBegan
const additionalClass = isEntitledToCloudBackups ? '' : 'faded cursor-default pointer-events-none'
return (
<div
className={`mr-1 ${isExpanded ? 'expanded' : ' '} ${
shouldShowEnableButton || backupFrequency ? 'flex justify-between items-center' : ''
}`}
>
<div>
<Subtitle className={additionalClass}>{providerName}</Subtitle>
{successfullyInstalled && <p>{providerName} has been successfully enabled.</p>}
</div>
{authBegan && (
<div>
<p className="sk-panel-row">
Complete authentication from the newly opened window. Upon completion, a confirmation code will be
displayed. Enter this code below:
</p>
<div className={'mt-1'}>
<input
className="sk-input sk-base center-text"
placeholder="Enter confirmation code"
value={confirmation}
onKeyPress={handleKeyPress}
onChange={handleChange}
/>
</div>
</div>
)}
{shouldShowEnableButton && (
<div>
<Button
variant="normal"
label="Enable"
className={`px-1 text-xs min-w-40 ${additionalClass}`}
onClick={installIntegration}
disabled={!isEntitledToCloudBackups}
/>
</div>
)}
{backupFrequency && (
<div className={'flex flex-col items-end'}>
<Button
className={`min-w-40 mb-2 ${additionalClass}`}
variant="normal"
label="Perform Backup"
onClick={performBackupNow}
/>
<Button className="min-w-40" variant="normal" label="Disable" onClick={disable} />
</div>
)}
</div>
)
}
export default CloudBackupProvider

View File

@@ -0,0 +1,154 @@
import CloudBackupProvider from './CloudBackupProvider'
import { useCallback, useEffect, useState, FunctionComponent, Fragment } from 'react'
import { WebApplication } from '@/Application/Application'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import {
FeatureStatus,
FeatureIdentifier,
CloudProvider,
MuteFailedCloudBackupsEmailsOption,
SettingName,
} from '@standardnotes/snjs'
import Switch from '@/Components/Switch/Switch'
import { convertStringifiedBooleanToBoolean } from '@/Utils'
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Constants/Strings'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
const providerData = [{ name: CloudProvider.Dropbox }, { name: CloudProvider.Google }, { name: CloudProvider.OneDrive }]
type Props = {
application: WebApplication
}
const CloudLink: FunctionComponent<Props> = ({ application }) => {
const [isEntitledToCloudBackups, setIsEntitledToCloudBackups] = useState(false)
const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const additionalClass = isEntitledToCloudBackups ? '' : 'faded cursor-default pointer-events-none'
const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => {
if (!application.getUser()) {
return
}
setIsLoading(true)
try {
const userSettings = await application.settings.listSettings()
setIsFailedCloudBackupEmailMuted(
convertStringifiedBooleanToBoolean(
userSettings.getSettingValue(
SettingName.MuteFailedCloudBackupsEmails,
MuteFailedCloudBackupsEmailsOption.NotMuted,
),
),
)
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
}
}, [application])
useEffect(() => {
const dailyDropboxBackupStatus = application.features.getFeatureStatus(FeatureIdentifier.DailyDropboxBackup)
const dailyGdriveBackupStatus = application.features.getFeatureStatus(FeatureIdentifier.DailyGDriveBackup)
const dailyOneDriveBackupStatus = application.features.getFeatureStatus(FeatureIdentifier.DailyOneDriveBackup)
const isCloudBackupsAllowed = [dailyDropboxBackupStatus, dailyGdriveBackupStatus, dailyOneDriveBackupStatus].every(
(status) => status === FeatureStatus.Entitled,
)
setIsEntitledToCloudBackups(isCloudBackupsAllowed)
loadIsFailedCloudBackupEmailMutedSetting().catch(console.error)
}, [application, loadIsFailedCloudBackupEmailMutedSetting])
const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
try {
await application.settings.updateSetting(settingName, payload)
return true
} catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
return false
}
}
const toggleMuteFailedCloudBackupEmails = async () => {
if (!isEntitledToCloudBackups) {
return
}
const previousValue = isFailedCloudBackupEmailMuted
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted)
const updateResult = await updateSetting(
SettingName.MuteFailedCloudBackupsEmails,
`${!isFailedCloudBackupEmailMuted}`,
)
if (!updateResult) {
setIsFailedCloudBackupEmailMuted(previousValue)
}
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Cloud Backups</Title>
{!isEntitledToCloudBackups && (
<>
<Text>
A <span className={'font-bold'}>Plus</span> or <span className={'font-bold'}>Pro</span> subscription plan
is required to enable Cloud Backups.{' '}
<a target="_blank" href="https://standardnotes.com/features">
Learn more
</a>
.
</Text>
<HorizontalSeparator classes="mt-3 mb-3" />
</>
)}
<div>
<Text className={additionalClass}>
Configure the integrations below to enable automatic daily backups of your encrypted data set to your
third-party cloud provider.
</Text>
<div>
<HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
<div>
{providerData.map(({ name }) => (
<Fragment key={name}>
<CloudBackupProvider
application={application}
providerName={name}
isEntitledToCloudBackups={isEntitledToCloudBackups}
/>
<HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
</Fragment>
))}
</div>
</div>
<div className={additionalClass}>
<Subtitle>Email preferences</Subtitle>
<div className="flex items-center justify-between mt-1">
<div className="flex flex-col">
<Text>Receive a notification email if a cloud backup fails.</Text>
</div>
{isLoading ? (
<div className={'sk-spinner info small'} />
) : (
<Switch
onChange={toggleMuteFailedCloudBackupEmails}
checked={!isFailedCloudBackupEmailMuted}
disabled={!isEntitledToCloudBackups}
/>
)}
</div>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default CloudLink

View File

@@ -0,0 +1,196 @@
import { isDesktopApplication } from '@/Utils'
import { alertDialog } from '@/Services/AlertService'
import {
STRING_IMPORT_SUCCESS,
STRING_INVALID_IMPORT_FILE,
STRING_IMPORTING_ZIP_FILE,
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
StringImportError,
STRING_E2E_ENABLED,
STRING_LOCAL_ENC_ENABLED,
STRING_ENC_NOT_ENABLED,
} from '@/Constants/Strings'
import { BackupFile } from '@standardnotes/snjs'
import { ChangeEventHandler, MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const DataBackups = ({ application, viewControllerManager }: Props) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isImportDataLoading, setIsImportDataLoading] = useState(false)
const {
isBackupEncrypted,
isEncryptionEnabled,
setIsBackupEncrypted,
setIsEncryptionEnabled,
setEncryptionStatusString,
} = viewControllerManager.accountMenuController
const refreshEncryptionStatus = useCallback(() => {
const hasUser = application.hasAccount()
const hasPasscode = application.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])
useEffect(() => {
refreshEncryptionStatus()
}, [refreshEncryptionStatus])
const downloadDataArchive = () => {
application.getArchiveService().downloadBackup(isBackupEncrypted).catch(console.error)
}
const readFile = async (file: File): Promise<any> => {
if (file.type === 'application/zip') {
application.alertService.alert(STRING_IMPORTING_ZIP_FILE).catch(console.error)
return
}
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).catch(console.error)
}
}
reader.readAsText(file)
})
}
const performImport = async (data: BackupFile) => {
setIsImportDataLoading(true)
const result = await application.mutator.importData(data)
setIsImportDataLoading(false)
if (!result) {
return
}
let statusText = STRING_IMPORT_SUCCESS
if ('error' in result) {
statusText = result.error.text
} else if (result.errorCount) {
statusText = StringImportError(result.errorCount)
}
void alertDialog({
text: statusText,
})
}
const importFileSelected: ChangeEventHandler<HTMLInputElement> = async (event) => {
const { files } = event.target
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: MouseEventHandler = (event) => {
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 (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Data Backups</Title>
{isDesktopApplication() && (
<Text className="mb-3">
Backups are automatically created on desktop and can be managed via the "Backups" top-level menu.
</Text>
)}
<Subtitle>Download a backup of all your data</Subtitle>
{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} />
<Subtitle>Encrypted</Subtitle>
</label>
<label className="sk-horizontal-group tight">
<input type="radio" onChange={() => setIsBackupEncrypted(false)} checked={!isBackupEncrypted} />
<Subtitle>Decrypted</Subtitle>
</label>
</div>
</form>
)}
<Button variant="normal" onClick={downloadDataArchive} label="Download backup" className="mt-2" />
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle>Import a previously saved backup file</Subtitle>
<div className="flex flex-row items-center mt-3">
<Button variant="normal" label="Import backup" onClick={handleImportFile} />
<input type="file" ref={fileInputRef} onChange={importFileSelected} className="hidden" />
{isImportDataLoading && <div className="sk-spinner normal info ml-4" />}
</div>
</PreferencesSegment>
</PreferencesGroup>
</>
)
}
export default observer(DataBackups)

View File

@@ -0,0 +1,179 @@
import { convertStringifiedBooleanToBoolean, isDesktopApplication } from '@/Utils'
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Constants/Strings'
import { useCallback, useEffect, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import Switch from '@/Components/Switch/Switch'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import {
FeatureStatus,
FeatureIdentifier,
EmailBackupFrequency,
MuteFailedBackupsEmailsOption,
SettingName,
} from '@standardnotes/snjs'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const EmailBackups = ({ application }: Props) => {
const [isLoading, setIsLoading] = useState(false)
const [emailFrequency, setEmailFrequency] = useState<EmailBackupFrequency>(EmailBackupFrequency.Disabled)
const [emailFrequencyOptions, setEmailFrequencyOptions] = useState<DropdownItem[]>([])
const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] = useState(true)
const [isEntitledToEmailBackups, setIsEntitledToEmailBackups] = useState(false)
const loadEmailFrequencySetting = useCallback(async () => {
if (!application.getUser()) {
return
}
setIsLoading(true)
try {
const userSettings = await application.settings.listSettings()
setEmailFrequency(
userSettings.getSettingValue<EmailBackupFrequency>(
SettingName.EmailBackupFrequency,
EmailBackupFrequency.Disabled,
),
)
setIsFailedBackupEmailMuted(
convertStringifiedBooleanToBoolean(
userSettings.getSettingValue<MuteFailedBackupsEmailsOption>(
SettingName.MuteFailedBackupsEmails,
MuteFailedBackupsEmailsOption.NotMuted,
),
),
)
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
}
}, [application])
useEffect(() => {
const emailBackupsFeatureStatus = application.features.getFeatureStatus(FeatureIdentifier.DailyEmailBackup)
setIsEntitledToEmailBackups(emailBackupsFeatureStatus === FeatureStatus.Entitled)
const frequencyOptions = []
for (const frequency in EmailBackupFrequency) {
const frequencyValue = EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency]
frequencyOptions.push({
value: frequencyValue,
label: application.settings.getEmailBackupFrequencyOptionLabel(frequencyValue),
})
}
setEmailFrequencyOptions(frequencyOptions)
loadEmailFrequencySetting().catch(console.error)
}, [application, loadEmailFrequencySetting])
const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
try {
await application.settings.updateSetting(settingName, payload, false)
return true
} catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
return false
}
}
const updateEmailFrequency = async (frequency: EmailBackupFrequency) => {
const previousFrequency = emailFrequency
setEmailFrequency(frequency)
const updateResult = await updateSetting(SettingName.EmailBackupFrequency, frequency)
if (!updateResult) {
setEmailFrequency(previousFrequency)
}
}
const toggleMuteFailedBackupEmails = async () => {
if (!isEntitledToEmailBackups) {
return
}
const previousValue = isFailedBackupEmailMuted
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted)
const updateResult = await updateSetting(SettingName.MuteFailedBackupsEmails, `${!isFailedBackupEmailMuted}`)
if (!updateResult) {
setIsFailedBackupEmailMuted(previousValue)
}
}
const handleEmailFrequencyChange = (item: string) => {
if (!isEntitledToEmailBackups) {
return
}
updateEmailFrequency(item as EmailBackupFrequency).catch(console.error)
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Email Backups</Title>
{!isEntitledToEmailBackups && (
<>
<Text>
A <span className={'font-bold'}>Plus</span> or <span className={'font-bold'}>Pro</span> subscription plan
is required to enable Email Backups.{' '}
<a target="_blank" href="https://standardnotes.com/features">
Learn more
</a>
.
</Text>
<HorizontalSeparator classes="my-4" />
</>
)}
<div className={isEntitledToEmailBackups ? '' : 'faded cursor-default pointer-events-none'}>
{!isDesktopApplication() && (
<Text className="mb-3">
Daily encrypted email backups of your entire data set delivered directly to your inbox.
</Text>
)}
<Subtitle>Email frequency</Subtitle>
<Text>How often to receive backups.</Text>
<div className="mt-2">
{isLoading ? (
<div className={'sk-spinner info small'} />
) : (
<Dropdown
id="def-editor-dropdown"
label="Select email frequency"
items={emailFrequencyOptions}
value={emailFrequency}
onChange={handleEmailFrequencyChange}
disabled={!isEntitledToEmailBackups}
/>
)}
</div>
<HorizontalSeparator classes="my-4" />
<Subtitle>Email preferences</Subtitle>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Text>Receive a notification email if an email backup fails.</Text>
</div>
{isLoading ? (
<div className={'sk-spinner info small'} />
) : (
<Switch
onChange={toggleMuteFailedBackupEmails}
checked={!isFailedBackupEmailMuted}
disabled={!isEntitledToEmailBackups}
/>
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(EmailBackups)

View File

@@ -0,0 +1,203 @@
import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { useCallback, useEffect, useMemo, useState, FunctionComponent } from 'react'
import Button from '@/Components/Button/Button'
import { FileBackupMetadataFile, FileBackupsConstantsV1, FileItem, FileHandleRead } from '@standardnotes/snjs'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Icon from '@/Components/Icon/Icon'
import { StreamingFileApi } from '@standardnotes/filepicker'
import { WebApplication } from '@/Application/Application'
import EncryptionStatusItem from '../../Security/EncryptionStatusItem'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const BackupsDropZone: FunctionComponent<Props> = ({ application }) => {
const [droppedFile, setDroppedFile] = useState<FileBackupMetadataFile | undefined>(undefined)
const [decryptedFileItem, setDecryptedFileItem] = useState<FileItem | undefined>(undefined)
const [binaryFile, setBinaryFile] = useState<FileHandleRead | undefined>(undefined)
const [isSavingAsDecrypted, setIsSavingAsDecrypted] = useState(false)
const fileSystem = useMemo(() => new StreamingFileApi(), [])
useEffect(() => {
if (droppedFile) {
void application.files.decryptBackupMetadataFile(droppedFile).then(setDecryptedFileItem)
} else {
setDecryptedFileItem(undefined)
}
}, [droppedFile, application])
const chooseRelatedBinaryFile = useCallback(async () => {
const selection = await application.files.selectFile(fileSystem)
if (selection === 'aborted' || selection === 'failed') {
return
}
setBinaryFile(selection)
}, [application, fileSystem])
const downloadBinaryFileAsDecrypted = useCallback(async () => {
if (!decryptedFileItem || !binaryFile) {
return
}
setIsSavingAsDecrypted(true)
const result = await application.files.readBackupFileAndSaveDecrypted(binaryFile, decryptedFileItem, fileSystem)
if (result === 'success') {
void application.alertService.alert(
`<strong>${decryptedFileItem.name}</strong> has been successfully decrypted and saved to your chosen directory.`,
)
setBinaryFile(undefined)
setDecryptedFileItem(undefined)
setDroppedFile(undefined)
} else if (result === 'failed') {
void application.alertService.alert(
'Unable to save file to local directory. This may be caused by failure to decrypt, or failure to save the file locally.',
)
}
setIsSavingAsDecrypted(false)
}, [decryptedFileItem, application, binaryFile, fileSystem])
const handleDragOver = useCallback((event: DragEvent) => {
event.stopPropagation()
}, [])
const handleDragIn = useCallback((event: DragEvent) => {
event.stopPropagation()
}, [])
const handleDragOut = useCallback((event: DragEvent) => {
event.stopPropagation()
}, [])
const handleDrop = useCallback(
async (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
const items = event.dataTransfer?.items
if (!items || items.length === 0) {
return
}
const item = items[0]
const file = item.getAsFile()
if (!file) {
return
}
const text = await file.text()
const type = application.files.isFileNameFileBackupRelated(file.name)
if (type === false) {
return
}
if (type === 'binary') {
void application.alertService.alert('Please drag the metadata file instead of the encrypted data file.')
return
}
try {
const metadata = JSON.parse(text) as FileBackupMetadataFile
setDroppedFile(metadata)
} catch (error) {
console.error(error)
}
event.dataTransfer.clearData()
},
[application],
)
useEffect(() => {
window.addEventListener('dragenter', handleDragIn)
window.addEventListener('dragleave', handleDragOut)
window.addEventListener('dragover', handleDragOver)
window.addEventListener('drop', handleDrop)
return () => {
window.removeEventListener('dragenter', handleDragIn)
window.removeEventListener('dragleave', handleDragOut)
window.removeEventListener('dragover', handleDragOver)
window.removeEventListener('drop', handleDrop)
}
}, [handleDragIn, handleDrop, handleDragOver, handleDragOut])
if (!droppedFile) {
return (
<Text>
To decrypt a backup file, drag and drop the file's respective <i>metadata.sn.json</i> file here.
</Text>
)
}
return (
<>
<PreferencesSegment>
{!decryptedFileItem && <Text>Attempting to decrypt metadata file...</Text>}
{decryptedFileItem && (
<>
<Title>Backup Decryption</Title>
<EncryptionStatusItem
status={decryptedFileItem.name}
icon={<Icon type="attachment-file" className="min-w-5 min-h-5" />}
checkmark={true}
/>
<HorizontalSeparator classes={'mt-3 mb-3'} />
<div className="flex justify-between items-center">
<div>
<Subtitle>1. Choose related data file</Subtitle>
<Text className={`text-xs mr-3 em ${binaryFile ? 'font-bold success' : ''}`}>
{droppedFile.file.uuid}/{FileBackupsConstantsV1.BinaryFileName}
</Text>
</div>
<div>
<Button
variant="normal"
label="Choose"
className={'px-1 text-xs min-w-40'}
onClick={chooseRelatedBinaryFile}
disabled={!!binaryFile}
/>
</div>
</div>
<HorizontalSeparator classes={'mt-3 mb-3'} />
<div className="flex justify-between items-center">
<Subtitle>2. Decrypt and save file to your computer</Subtitle>
<div>
<Button
variant="normal"
label={isSavingAsDecrypted ? undefined : 'Save'}
className={'px-1 text-xs min-w-40'}
onClick={downloadBinaryFileAsDecrypted}
disabled={isSavingAsDecrypted || !binaryFile}
>
{isSavingAsDecrypted && (
<div className="flex justify-center w-full">
<div className="sk-spinner w-5 h-5 spinner-info"></div>
</div>
)}
</Button>
</div>
</div>
</>
)}
</PreferencesSegment>
</>
)
}
export default BackupsDropZone

View File

@@ -0,0 +1,36 @@
import { Subtitle, Title, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
import { WebApplication } from '@/Application/Application'
import { useMemo } from 'react'
import BackupsDropZone from './BackupsDropZone'
import FileBackupsDesktop from './FileBackupsDesktop'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
type Props = {
application: WebApplication
}
const FileBackupsCrossPlatform = ({ application }: Props) => {
const fileBackupsService = useMemo(() => application.fileBackups, [application])
return fileBackupsService ? (
<FileBackupsDesktop application={application} backupsService={fileBackupsService} />
) : (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>File Backups</Title>
<Subtitle>Automatically save encrypted backups of files uploaded to any device to this computer.</Subtitle>
<Text className="mt-3">To enable file backups, use the Standard Notes desktop application.</Text>
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<BackupsDropZone application={application} />
</PreferencesSegment>
</PreferencesGroup>
</>
)
}
export default FileBackupsCrossPlatform

View File

@@ -0,0 +1,122 @@
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { useCallback, useEffect, useState } from 'react'
import Button from '@/Components/Button/Button'
import Switch from '@/Components/Switch/Switch'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Icon from '@/Components/Icon/Icon'
import BackupsDropZone from './BackupsDropZone'
import EncryptionStatusItem from '../../Security/EncryptionStatusItem'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
backupsService: NonNullable<WebApplication['fileBackups']>
}
const FileBackupsDesktop = ({ application, backupsService }: Props) => {
const [backupsEnabled, setBackupsEnabled] = useState(false)
const [backupsLocation, setBackupsLocation] = useState('')
useEffect(() => {
void backupsService.isFilesBackupsEnabled().then(setBackupsEnabled)
}, [backupsService])
useEffect(() => {
if (backupsEnabled) {
void backupsService.getFilesBackupsLocation().then(setBackupsLocation)
}
}, [backupsService, backupsEnabled])
const changeBackupsLocation = useCallback(async () => {
await backupsService.changeFilesBackupsLocation()
setBackupsLocation(await backupsService.getFilesBackupsLocation())
}, [backupsService])
const openBackupsLocation = useCallback(async () => {
await backupsService.openFilesBackupsLocation()
}, [backupsService])
const toggleBackups = useCallback(async () => {
if (backupsEnabled) {
await backupsService.disableFilesBackups()
} else {
await backupsService.enableFilesBackups()
}
setBackupsEnabled(await backupsService.isFilesBackupsEnabled())
}, [backupsService, backupsEnabled])
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>File Backups</Title>
<div className="flex items-center justify-between">
<div className="flex flex-col mr-10">
<Subtitle>
Automatically save encrypted backups of files uploaded on any device to this computer.
</Subtitle>
</div>
<Switch onChange={toggleBackups} checked={backupsEnabled} />
</div>
{!backupsEnabled && (
<>
<HorizontalSeparator classes="mt-2.5 mb-4" />
<Text>File backups are not enabled. Enable to choose where your files are backed up.</Text>
</>
)}
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
{backupsEnabled && (
<>
<PreferencesSegment>
<>
<Text className="mb-3">
Files backups are enabled. When you upload a new file on any device and open this application, files
will be backed up in encrypted form to:
</Text>
<EncryptionStatusItem
status={backupsLocation}
icon={<Icon type="attachment-file" className="min-w-5 min-h-5" />}
checkmark={false}
/>
<div className="flex flex-row mt-2.5">
<Button
variant="normal"
label="Open Backups Location"
className={'mr-3 text-xs'}
onClick={openBackupsLocation}
/>
<Button
variant="normal"
label="Change Backups Location"
className={'mr-3 text-xs'}
onClick={changeBackupsLocation}
/>
</div>
</>
</PreferencesSegment>
</>
)}
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<BackupsDropZone application={application} />
</PreferencesSegment>
</PreferencesGroup>
</>
)
}
export default observer(FileBackupsDesktop)

View File

@@ -0,0 +1,82 @@
import { FunctionComponent } from 'react'
import { Title, Subtitle, Text, LinkButton } from '@/Components/Preferences/PreferencesComponents/Content'
import PreferencesPane from '../PreferencesComponents/PreferencesPane'
import PreferencesGroup from '../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../PreferencesComponents/PreferencesSegment'
const CloudLink: FunctionComponent = () => (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Frequently asked questions</Title>
<div className="h-2 w-full" />
<Subtitle>Who can read my private notes?</Subtitle>
<Text>
Quite simply: no one but you. Not us, not your ISP, not a hacker, and not a government agency. As long as you
keep your password safe, and your password is reasonably strong, then you are the only person in the world
with the ability to decrypt your notes. For more on how we handle your privacy and security, check out our
easy to read{' '}
<a target="_blank" href="https://standardnotes.com/privacy">
Privacy Manifesto.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I collaborate with others on a note?</Subtitle>
<Text>
Because of our encrypted architecture, Standard Notes does not currently provide a real-time collaboration
solution. Multiple users can share the same account however, but editing at the same time may result in sync
conflicts, which may result in the duplication of notes.
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
<Text>
Standard Notes can be used totally offline without an account, and without an internet connection. You can
find{' '}
<a target="_blank" href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline">
more details here.
</a>
</Text>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<LinkButton className="mt-3" label="Open FAQ" link="https://standardnotes.com/help" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community forum</Title>
<Text>
If you have an issue, found a bug or want to suggest a feature, you can browse or post to the forum. Its
recommended for non-account related issues. Please read our{' '}
<a target="_blank" href="https://standardnotes.com/longevity/">
Longevity statement
</a>{' '}
before advocating for a feature request.
</Text>
<LinkButton className="mt-3" label="Go to the forum" link="https://forum.standardnotes.org/" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community groups</Title>
<Text>
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your feedback with us? Join
the Standard Notes community groups for discussions on security, themes, editors and more.
</Text>
<LinkButton className="mt-3" link="https://standardnotes.com/slack" label="Join our Slack" />
<LinkButton className="mt-3" link="https://standardnotes.com/discord" label="Join our Discord" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Account related issue?</Title>
<Text>Send an email to help@standardnotes.com and well sort it out.</Text>
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
)
export default CloudLink

View File

@@ -0,0 +1,3 @@
import { SNActionsExtension, SNComponent, SNTheme } from '@standardnotes/snjs'
export type AnyExtension = SNComponent | SNTheme | SNActionsExtension

View File

@@ -0,0 +1,69 @@
import { DisplayStringForContentType } from '@standardnotes/snjs'
import Button from '@/Components/Button/Button'
import { Fragment, FunctionComponent } from 'react'
import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { AnyExtension } from './AnyExtension'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
const ConfirmCustomExtension: FunctionComponent<{
component: AnyExtension
callback: (confirmed: boolean) => void
}> = ({ component, callback }) => {
const fields = [
{
label: 'Name',
value: component.package_info.name,
},
{
label: 'Description',
value: component.package_info.description,
},
{
label: 'Version',
value: component.package_info.version,
},
{
label: 'Hosted URL',
value: component.thirdPartyPackageInfo.url,
},
{
label: 'Download URL',
value: component.package_info.download_url,
},
{
label: 'Extension Type',
value: DisplayStringForContentType(component.content_type),
},
]
return (
<PreferencesSegment>
<Title>Confirm Extension</Title>
{fields.map((field) => {
if (!field.value) {
return undefined
}
return (
<Fragment key={field.value}>
<Subtitle>{field.label}</Subtitle>
<Text className={'wrap'}>{field.value}</Text>
<div className="min-h-2" />
</Fragment>
)
})}
<div className="min-h-3" />
<div className="flex flex-row">
<Button className="min-w-20" variant="normal" label="Cancel" onClick={() => callback(false)} />
<div className="min-w-3" />
<Button className="min-w-20" variant="normal" label="Install" onClick={() => callback(true)} />
</div>
</PreferencesSegment>
)
}
export default ConfirmCustomExtension

View File

@@ -0,0 +1,76 @@
import { FunctionComponent, useState, useRef, useEffect } from 'react'
type Props = {
extensionName: string
changeName: (newName: string) => void
isThirdParty: boolean
}
const ExtensionInfoCell: FunctionComponent<Props> = ({ extensionName, changeName, isThirdParty }) => {
const [isRenaming, setIsRenaming] = useState(false)
const [newExtensionName, setNewExtensionName] = useState<string>(extensionName)
const renameable = isThirdParty
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isRenaming) {
inputRef.current?.focus()
}
}, [inputRef, isRenaming])
const startRenaming = () => {
setNewExtensionName(extensionName)
setIsRenaming(true)
}
const cancelRename = () => {
setNewExtensionName(extensionName)
setIsRenaming(false)
}
const confirmRename = () => {
if (!newExtensionName) {
return
}
changeName(newExtensionName)
setIsRenaming(false)
}
return (
<div className="flex flex-row mr-3 items-center">
<input
ref={inputRef}
disabled={!isRenaming || !renameable}
autoComplete="off"
className="flex-grow text-base font-bold no-border bg-default px-0 color-text"
type="text"
value={newExtensionName}
onChange={({ target: input }) => setNewExtensionName((input as HTMLInputElement)?.value)}
/>
<div className="min-w-3" />
{isRenaming && (
<>
<a className="pt-1 cursor-pointer" onClick={confirmRename}>
Confirm
</a>
<div className="min-w-3" />
<a className="pt-1 cursor-pointer" onClick={cancelRename}>
Cancel
</a>
</>
)}
{renameable && !isRenaming && (
<a className="pt-1 cursor-pointer" onClick={startRenaming}>
Rename
</a>
)}
</div>
)
}
export default ExtensionInfoCell

View File

@@ -0,0 +1,80 @@
import { FunctionComponent, useState } from 'react'
import { ComponentMutator, SNComponent } from '@standardnotes/snjs'
import { SubtitleLight } from '@/Components/Preferences/PreferencesComponents/Content'
import Switch from '@/Components/Switch/Switch'
import Button from '@/Components/Button/Button'
import ExtensionInfoCell from './ExtensionInfoCell'
import { ExtensionItemProps } from './ExtensionItemProps'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
const UseHosted: FunctionComponent<{
offlineOnly: boolean
toggleOfflineOnly: () => void
}> = ({ offlineOnly, toggleOfflineOnly }) => (
<div className="flex flex-row">
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
<Switch onChange={toggleOfflineOnly} checked={!offlineOnly} />
</div>
)
const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({ application, extension, uninstall }) => {
const [offlineOnly, setOfflineOnly] = useState(extension instanceof SNComponent ? extension.offlineOnly : false)
const [extensionName, setExtensionName] = useState(extension.displayName)
const toggleOfflineOnly = () => {
const newOfflineOnly = !offlineOnly
setOfflineOnly(newOfflineOnly)
application.mutator
.changeAndSaveItem<ComponentMutator>(extension, (mutator) => {
mutator.offlineOnly = newOfflineOnly
})
.then((item) => {
const component = item as SNComponent
setOfflineOnly(component.offlineOnly)
})
.catch((e) => {
console.error(e)
})
}
const changeExtensionName = (newName: string) => {
setExtensionName(newName)
application.mutator
.changeAndSaveItem<ComponentMutator>(extension, (mutator) => {
mutator.name = newName
})
.then((item) => {
const component = item as SNComponent
setExtensionName(component.name)
})
.catch(console.error)
}
const localInstallable = extension.package_info.download_url
const isThirParty = 'identifier' in extension && application.features.isThirdPartyFeature(extension.identifier)
return (
<PreferencesSegment classes={'mb-5'}>
<ExtensionInfoCell isThirdParty={isThirParty} extensionName={extensionName} changeName={changeExtensionName} />
<div className="min-h-2" />
{isThirParty && localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfflineOnly={toggleOfflineOnly} />}
<>
<div className="min-h-2" />
<div className="flex flex-row">
<Button
className="min-w-20"
variant="normal"
label={isThirParty ? 'Uninstall' : 'Reset'}
onClick={() => uninstall(extension)}
/>
</div>
</>
</PreferencesSegment>
)
}
export default ExtensionItem

View File

@@ -0,0 +1,11 @@
import { WebApplication } from '@/Application/Application'
import { AnyExtension } from './AnyExtension'
export interface ExtensionItemProps {
application: WebApplication
extension: AnyExtension
first: boolean
latestVersion: string | undefined
uninstall: (extension: AnyExtension) => void
toggleActivate?: (extension: AnyExtension) => void
}

View File

@@ -0,0 +1,141 @@
import { ButtonType, ContentType, SNComponent } from '@standardnotes/snjs'
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useEffect, useRef, useState } from 'react'
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { ExtensionsLatestVersions } from './ExtensionsLatestVersions'
import ExtensionItem from './ExtensionItem'
import ConfirmCustomExtension from './ConfirmCustomExtension'
import { AnyExtension } from './AnyExtension'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
const loadExtensions = (application: WebApplication) =>
application.items.getItems([ContentType.ActionsExtension, ContentType.Component, ContentType.Theme]) as AnyExtension[]
type Props = {
application: WebApplication
extensionsLatestVersions: ExtensionsLatestVersions
className?: string
}
const Extensions: FunctionComponent<Props> = ({ application, extensionsLatestVersions, className = '' }) => {
const [customUrl, setCustomUrl] = useState('')
const [confirmableExtension, setConfirmableExtension] = useState<AnyExtension | undefined>(undefined)
const [extensions, setExtensions] = useState(loadExtensions(application))
const confirmableEnd = useRef<HTMLDivElement>(null)
useEffect(() => {
if (confirmableExtension) {
confirmableEnd.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [confirmableExtension, confirmableEnd])
const uninstallExtension = async (extension: AnyExtension) => {
application.alertService
.confirm(
'Are you sure you want to uninstall this extension? Note that extensions managed by your subscription will automatically be re-installed on application restart.',
'Uninstall Extension?',
'Uninstall',
ButtonType.Danger,
'Cancel',
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await application.mutator.deleteItem(extension)
setExtensions(loadExtensions(application))
}
})
.catch((err: string) => {
application.alertService.alert(err).catch(console.error)
})
}
const submitExtensionUrl = async (url: string) => {
const component = await application.features.downloadExternalFeature(url)
if (component) {
setConfirmableExtension(component)
}
}
const handleConfirmExtensionSubmit = async (confirm: boolean) => {
if (confirm) {
confirmExtension().catch(console.error)
}
setConfirmableExtension(undefined)
setCustomUrl('')
}
const confirmExtension = async () => {
await application.mutator.insertItem(confirmableExtension as AnyExtension)
application.sync.sync().catch(console.error)
setExtensions(loadExtensions(application))
}
const visibleExtensions = extensions.filter((extension) => {
const hasPackageInfo = extension.package_info != undefined
if (!hasPackageInfo) {
return false
}
if (extension instanceof SNComponent) {
return !['modal', 'rooms'].includes(extension.area)
}
return true
})
return (
<div className={className}>
{visibleExtensions.length > 0 && (
<div>
{visibleExtensions
.sort((e1, e2) => e1.displayName?.toLowerCase().localeCompare(e2.displayName?.toLowerCase()))
.map((extension, i) => (
<ExtensionItem
key={extension.uuid}
application={application}
extension={extension}
latestVersion={extensionsLatestVersions.getVersion(extension)}
first={i === 0}
uninstall={uninstallExtension}
/>
))}
</div>
)}
<div>
{!confirmableExtension && (
<PreferencesSegment>
<Title>Install Custom Extension</Title>
<DecoratedInput
placeholder={'Enter Extension URL'}
value={customUrl}
onChange={(value) => {
setCustomUrl(value)
}}
/>
<div className="min-h-2" />
<Button
className="min-w-20"
variant="normal"
label="Install"
onClick={() => submitExtensionUrl(customUrl)}
/>
</PreferencesSegment>
)}
{confirmableExtension && (
<PreferencesSegment>
<ConfirmCustomExtension component={confirmableExtension} callback={handleConfirmExtensionSubmit} />
<div ref={confirmableEnd} />
</PreferencesSegment>
)}
</div>
</div>
)
}
export default observer(Extensions)

View File

@@ -0,0 +1,39 @@
import { WebApplication } from '@/Application/Application'
import { ClientDisplayableError, FeatureDescription } from '@standardnotes/snjs'
import { makeAutoObservable, observable } from 'mobx'
import { AnyExtension } from './AnyExtension'
export class ExtensionsLatestVersions {
static async load(application: WebApplication): Promise<ExtensionsLatestVersions | undefined> {
const response = await application.getAvailableSubscriptions()
if (response instanceof ClientDisplayableError) {
return undefined
}
const versionMap: Map<string, string> = new Map()
collectFeatures(response.PLUS_PLAN?.features as FeatureDescription[], versionMap)
collectFeatures(response.PRO_PLAN?.features as FeatureDescription[], versionMap)
return new ExtensionsLatestVersions(versionMap)
}
constructor(private readonly latestVersionsMap: Map<string, string>) {
makeAutoObservable<ExtensionsLatestVersions, 'latestVersionsMap'>(this, {
latestVersionsMap: observable.ref,
})
}
getVersion(extension: AnyExtension): string | undefined {
return this.latestVersionsMap.get(extension.package_info.identifier)
}
}
function collectFeatures(features: FeatureDescription[] | undefined, versionMap: Map<string, string>) {
if (features == undefined) {
return
}
for (const feature of features) {
versionMap.set(feature.identifier, feature.version as string)
}
}

View File

@@ -0,0 +1,152 @@
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { FeatureIdentifier, PrefKey, ComponentArea, ComponentMutator, SNComponent } from '@standardnotes/snjs'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useEffect, useState } from 'react'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Switch from '@/Components/Switch/Switch'
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
type EditorOption = DropdownItem & {
value: FeatureIdentifier | 'plain-editor'
}
const makeEditorDefault = (application: WebApplication, component: SNComponent, currentDefault: SNComponent) => {
if (currentDefault) {
removeEditorDefault(application, currentDefault)
}
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = true
})
.catch(console.error)
}
const removeEditorDefault = (application: WebApplication, component: SNComponent) => {
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = false
})
.catch(console.error)
}
const getDefaultEditor = (application: WebApplication) => {
return application.componentManager.componentsForArea(ComponentArea.Editor).filter((e) => e.isDefaultEditor())[0]
}
const Defaults: FunctionComponent<Props> = ({ application }) => {
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
const [defaultEditorValue, setDefaultEditorValue] = useState(
() => getDefaultEditor(application)?.package_info?.identifier || 'plain-editor',
)
const [spellcheck, setSpellcheck] = useState(() => application.getPreference(PrefKey.EditorSpellcheck, true))
const [addNoteToParentFolders, setAddNoteToParentFolders] = useState(() =>
application.getPreference(PrefKey.NoteAddToParentFolders, true),
)
const toggleSpellcheck = () => {
setSpellcheck(!spellcheck)
application.toggleGlobalSpellcheck().catch(console.error)
}
useEffect(() => {
const editors = application.componentManager
.componentsForArea(ComponentArea.Editor)
.map((editor): EditorOption => {
const identifier = editor.package_info.identifier
const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type)
return {
label: editor.displayName,
value: identifier,
...(iconType ? { icon: iconType } : null),
...(tint ? { iconClassName: `color-accessory-tint-${tint}` } : null),
}
})
.concat([
{
icon: 'plain-text',
iconClassName: 'color-accessory-tint-1',
label: PLAIN_EDITOR_NAME,
value: 'plain-editor',
},
])
.sort((a, b) => {
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1
})
setEditorItems(editors)
}, [application])
const setDefaultEditor = (value: string) => {
setDefaultEditorValue(value as FeatureIdentifier)
const editors = application.componentManager.componentsForArea(ComponentArea.Editor)
const currentDefault = getDefaultEditor(application)
if (value !== 'plain-editor') {
const editorComponent = editors.filter((e) => e.package_info.identifier === value)[0]
makeEditorDefault(application, editorComponent, currentDefault)
} else {
removeEditorDefault(application, currentDefault)
}
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Defaults</Title>
<div>
<Subtitle>Default Note Type</Subtitle>
<Text>New notes will be created using this type.</Text>
<div className="mt-2">
<Dropdown
id="def-editor-dropdown"
label="Select the default note type"
items={editorItems}
value={defaultEditorValue}
onChange={setDefaultEditor}
/>
</div>
</div>
<HorizontalSeparator classes="my-4" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Spellcheck</Subtitle>
<Text>
The default spellcheck value for new notes. Spellcheck can be configured per note from the note context
menu. Spellcheck may degrade overall typing performance with long notes.
</Text>
</div>
<Switch onChange={toggleSpellcheck} checked={spellcheck} />
</div>
<HorizontalSeparator classes="my-4" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Add all parent tags when adding a nested tag to a note</Subtitle>
<Text>When enabled, adding a nested tag to a note will automatically add all associated parent tags.</Text>
</div>
<Switch
onChange={() => {
application.setPreference(PrefKey.NoteAddToParentFolders, !addNoteToParentFolders).catch(console.error)
setAddNoteToParentFolders(!addNoteToParentFolders)
}}
checked={addNoteToParentFolders}
/>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default Defaults

View File

@@ -0,0 +1,31 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FunctionComponent } from 'react'
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
import { observer } from 'mobx-react-lite'
import Tools from './Tools'
import Defaults from './Defaults'
import LabsPane from './Labs'
import Advanced from '@/Components/Preferences/Panes/Account/Advanced'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
extensionsLatestVersions: ExtensionsLatestVersions
}
const General: FunctionComponent<Props> = ({ viewControllerManager, application, extensionsLatestVersions }) => (
<PreferencesPane>
<Tools application={application} />
<Defaults application={application} />
<LabsPane application={application} />
<Advanced
application={application}
viewControllerManager={viewControllerManager}
extensionsLatestVersions={extensionsLatestVersions}
/>
</PreferencesPane>
)
export default observer(General)

View File

@@ -0,0 +1,90 @@
import Switch from '@/Components/Switch/Switch'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { FeatureIdentifier, FeatureStatus, FindNativeFeature } from '@standardnotes/snjs'
import { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type ExperimentalFeatureItem = {
identifier: FeatureIdentifier
name: string
description: string
isEnabled: boolean
isEntitled: boolean
}
type Props = {
application: WebApplication
}
const LabsPane: FunctionComponent<Props> = ({ application }) => {
const [experimentalFeatures, setExperimentalFeatures] = useState<ExperimentalFeatureItem[]>([])
const reloadExperimentalFeatures = useCallback(() => {
const experimentalFeatures = application.features.getExperimentalFeatures().map((featureIdentifier) => {
const feature = FindNativeFeature(featureIdentifier)
return {
identifier: featureIdentifier,
name: feature?.name ?? featureIdentifier,
description: feature?.description ?? '',
isEnabled: application.features.isExperimentalFeatureEnabled(featureIdentifier),
isEntitled: application.features.getFeatureStatus(featureIdentifier) === FeatureStatus.Entitled,
}
})
setExperimentalFeatures(experimentalFeatures)
}, [application])
useEffect(() => {
reloadExperimentalFeatures()
}, [reloadExperimentalFeatures])
const premiumModal = usePremiumModal()
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Labs</Title>
<div>
{experimentalFeatures.map(({ identifier, name, description, isEnabled, isEntitled }, index: number) => {
const toggleFeature = () => {
if (!isEntitled) {
premiumModal.activate(name)
return
}
application.features.toggleExperimentalFeature(identifier)
reloadExperimentalFeatures()
}
const showHorizontalSeparator = experimentalFeatures.length > 1 && index !== experimentalFeatures.length - 1
return (
<Fragment key={identifier}>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>{name}</Subtitle>
<Text>{description}</Text>
</div>
<Switch onChange={toggleFeature} checked={isEnabled} />
</div>
{showHorizontalSeparator && <HorizontalSeparator classes="mt-2.5 mb-3" />}
</Fragment>
)
})}
{experimentalFeatures.length === 0 && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Text>No experimental features available.</Text>
</div>
</div>
)}
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default LabsPane

View File

@@ -0,0 +1,59 @@
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Switch from '@/Components/Switch/Switch'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useState } from 'react'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const Tools: FunctionComponent<Props> = ({ application }: Props) => {
const [monospaceFont, setMonospaceFont] = useState(() =>
application.getPreference(PrefKey.EditorMonospaceEnabled, true),
)
const [marginResizers, setMarginResizers] = useState(() =>
application.getPreference(PrefKey.EditorResizersEnabled, true),
)
const toggleMonospaceFont = () => {
setMonospaceFont(!monospaceFont)
application.setPreference(PrefKey.EditorMonospaceEnabled, !monospaceFont).catch(console.error)
}
const toggleMarginResizers = () => {
setMarginResizers(!marginResizers)
application.setPreference(PrefKey.EditorResizersEnabled, !marginResizers).catch(console.error)
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Tools</Title>
<div>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Monospace Font</Subtitle>
<Text>Toggles the font style in the Plain Text editor.</Text>
</div>
<Switch onChange={toggleMonospaceFont} checked={monospaceFont} />
</div>
<HorizontalSeparator classes="my-4" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Margin Resizers</Subtitle>
<Text>Allows left and right editor margins to be resized.</Text>
</div>
<Switch onChange={toggleMarginResizers} checked={marginResizers} />
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Tools)

View File

@@ -0,0 +1,86 @@
import { FunctionComponent } from 'react'
import { Title, Subtitle, Text, LinkButton } from '@/Components/Preferences/PreferencesComponents/Content'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import PreferencesPane from '../PreferencesComponents/PreferencesPane'
import PreferencesGroup from '../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../PreferencesComponents/PreferencesSegment'
const HelpAndFeedback: FunctionComponent = () => (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Frequently asked questions</Title>
<div className="h-2 w-full" />
<Subtitle>Who can read my private notes?</Subtitle>
<Text>
Quite simply: no one but you. Not us, not your ISP, not a hacker, and not a government agency. As long as you
keep your password safe, and your password is reasonably strong, then you are the only person in the world
with the ability to decrypt your notes. For more on how we handle your privacy and security, check out our
easy to read{' '}
<a target="_blank" href="https://standardnotes.com/privacy">
Privacy Manifesto.
</a>
</Text>
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle>Can I collaborate with others on a note?</Subtitle>
<Text>
Because of our encrypted architecture, Standard Notes does not currently provide a real-time collaboration
solution. Multiple users can share the same account however, but editing at the same time may result in sync
conflicts, which may result in the duplication of notes.
</Text>
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
<Text>
Standard Notes can be used totally offline without an account, and without an internet connection. You can
find{' '}
<a target="_blank" href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline">
more details here.
</a>
</Text>
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<LinkButton className="mt-3" label="Open FAQ" link="https://standardnotes.com/help" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community forum</Title>
<Text>
If you have an issue, found a bug or want to suggest a feature, you can browse or post to the forum. Its
recommended for non-account related issues. Please read our{' '}
<a target="_blank" href="https://standardnotes.com/longevity/">
Longevity statement
</a>{' '}
before advocating for a feature request.
</Text>
<LinkButton className="mt-3" label="Go to the forum" link="https://forum.standardnotes.org/" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Community groups</Title>
<Text>
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your feedback with us? Join
the Standard Notes community groups for discussions on security, themes, editors and more.
</Text>
<LinkButton className="mt-3" link="https://standardnotes.com/slack" label="Join our Slack" />
<LinkButton className="mt-3" link="https://standardnotes.com/discord" label="Join our Discord" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Account related issue?</Title>
<Text>Send an email to help@standardnotes.com and well sort it out.</Text>
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
)
export default HelpAndFeedback

View File

@@ -0,0 +1,112 @@
import { Title, Subtitle, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/Application/Application'
import { ButtonType, ListedAccount } from '@standardnotes/snjs'
import { useCallback, useEffect, useState } from 'react'
import ListedAccountItem from './ListedAccountItem'
import Button from '@/Components/Button/Button'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const Listed = ({ application }: Props) => {
const [accounts, setAccounts] = useState<ListedAccount[]>([])
const [requestingAccount, setRequestingAccount] = useState<boolean>()
const reloadAccounts = useCallback(async () => {
setAccounts(await application.getListedAccounts())
}, [application])
useEffect(() => {
reloadAccounts().catch(console.error)
}, [reloadAccounts])
const registerNewAccount = useCallback(() => {
setRequestingAccount(true)
const requestAccount = async () => {
const account = await application.requestNewListedAccount()
if (account) {
const openSettings = await application.alertService.confirm(
'Your new Listed blog has been successfully created!' +
' You can publish a new post to your blog from Standard Notes via the' +
' <i>Actions</i> menu in the editor pane. Open your blog settings to begin setting it up.',
undefined,
'Open Settings',
ButtonType.Info,
'Later',
)
reloadAccounts().catch(console.error)
if (openSettings) {
const info = await application.getListedAccountInfo(account)
if (info) {
application.deviceInterface.openUrl(info?.settings_url)
}
}
}
setRequestingAccount(false)
}
requestAccount().catch(console.error)
}, [application, reloadAccounts])
return (
<PreferencesPane>
{accounts.length > 0 && (
<PreferencesGroup>
<PreferencesSegment>
<Title>Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed</Title>
<div className="h-2 w-full" />
{accounts.map((item, index, array) => {
return (
<ListedAccountItem
account={item}
showSeparator={index !== array.length - 1}
key={item.authorId}
application={application}
/>
)
})}
</PreferencesSegment>
</PreferencesGroup>
)}
<PreferencesGroup>
<PreferencesSegment>
<Title>About Listed</Title>
<div className="h-2 w-full" />
<Subtitle>What is Listed?</Subtitle>
<Text>
Listed is a free blogging platform that allows you to create a public journal published directly from your
notes.{' '}
<a target="_blank" href="https://listed.to" rel="noreferrer noopener">
Learn more
</a>
</Text>
</PreferencesSegment>
{application.getUser() && (
<>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle>Get Started</Subtitle>
<Text>Create a free Listed author account to get started.</Text>
<Button
className="mt-3"
variant="normal"
disabled={requestingAccount}
label={requestingAccount ? 'Creating account...' : 'Create new author'}
onClick={registerNewAccount}
/>
</PreferencesSegment>
</>
)}
</PreferencesGroup>
</PreferencesPane>
)
}
export default observer(Listed)

View File

@@ -0,0 +1,45 @@
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import { LinkButton, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { ListedAccount, ListedAccountInfo } from '@standardnotes/snjs'
import { FunctionComponent, useEffect, useState } from 'react'
type Props = {
account: ListedAccount
showSeparator: boolean
application: WebApplication
}
const ListedAccountItem: FunctionComponent<Props> = ({ account, showSeparator, application }) => {
const [isLoading, setIsLoading] = useState(false)
const [accountInfo, setAccountInfo] = useState<ListedAccountInfo>()
useEffect(() => {
const loadAccount = async () => {
setIsLoading(true)
const info = await application.getListedAccountInfo(account)
setAccountInfo(info)
setIsLoading(false)
}
loadAccount().catch(console.error)
}, [account, application])
return (
<>
<Subtitle className="em">{accountInfo?.display_name}</Subtitle>
<div className="mb-2" />
<div className="flex">
{isLoading ? <div className="sk-spinner small info"></div> : null}
{accountInfo && (
<>
<LinkButton className="mr-2" label="Open Blog" link={accountInfo.author_url} />
<LinkButton className="mr-2" label="Settings" link={accountInfo.settings_url} />
</>
)}
</div>
{showSeparator && <HorizontalSeparator classes="mt-2.5 mb-3" />}
</>
)
}
export default ListedAccountItem

View File

@@ -0,0 +1,36 @@
import { STRING_E2E_ENABLED, STRING_ENC_NOT_ENABLED, STRING_LOCAL_ENC_ENABLED } from '@/Constants/Strings'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { Title, Text } from '../../PreferencesComponents/Content'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import EncryptionEnabled from './EncryptionEnabled'
type Props = { viewControllerManager: ViewControllerManager }
const Encryption: FunctionComponent<Props> = ({ viewControllerManager }) => {
const app = viewControllerManager.application
const hasUser = app.hasAccount()
const hasPasscode = app.hasPasscode()
const isEncryptionEnabled = app.isEncryptionAvailable()
const encryptionStatusString = hasUser
? STRING_E2E_ENABLED
: hasPasscode
? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Encryption</Title>
<Text>{encryptionStatusString}</Text>
{isEncryptionEnabled && <EncryptionEnabled viewControllerManager={viewControllerManager} />}
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Encryption)

View File

@@ -0,0 +1,39 @@
import Icon from '@/Components/Icon/Icon'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import EncryptionStatusItem from './EncryptionStatusItem'
import { formatCount } from './formatCount'
type Props = {
viewControllerManager: ViewControllerManager
}
const EncryptionEnabled: FunctionComponent<Props> = ({ viewControllerManager }) => {
const count = viewControllerManager.accountMenuController.structuredNotesAndTagsCount
const notes = formatCount(count.notes, 'notes')
const tags = formatCount(count.tags, 'tags')
const archived = formatCount(count.archived, 'archived notes')
const deleted = formatCount(count.deleted, 'trashed notes')
const noteIcon = <Icon type="rich-text" className="min-w-5 min-h-5" />
const tagIcon = <Icon type="hashtag" className="min-w-5 min-h-5" />
const archiveIcon = <Icon type="archive" className="min-w-5 min-h-5" />
const trashIcon = <Icon type="trash" className="min-w-5 min-h-5" />
return (
<>
<div className="flex flex-row items-start pb-1 pt-1.5">
<EncryptionStatusItem status={notes} icon={noteIcon} />
<div className="min-w-3" />
<EncryptionStatusItem status={tags} icon={tagIcon} />
</div>
<div className="flex flex-row items-start">
<EncryptionStatusItem status={archived} icon={archiveIcon} />
<div className="min-w-3" />
<EncryptionStatusItem status={deleted} icon={trashIcon} />
</div>
</>
)
}
export default observer(EncryptionEnabled)

View File

@@ -0,0 +1,20 @@
import Icon from '@/Components/Icon/Icon'
import { FunctionComponent, ReactNode } from 'react'
type Props = {
icon: ReactNode
status: string
checkmark?: boolean
}
const EncryptionStatusItem: FunctionComponent<Props> = ({ icon, status, checkmark = true }) => (
<div className="w-full rounded py-1.5 px-3 text-input my-1 min-h-8 flex flex-row items-center bg-contrast no-border focus-within:ring-info">
{icon}
<div className="min-w-3 min-h-1" />
<div className="flex-grow color-text text-sm">{status}</div>
<div className="min-w-3 min-h-1" />
{checkmark && <Icon className="success min-w-4 min-h-4" type="check-bold" />}
</div>
)
export default EncryptionStatusItem

View File

@@ -0,0 +1,140 @@
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { Fragment, FunctionComponent, useState } from 'react'
import { Text, Title, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import {
ButtonType,
ClientDisplayableError,
DisplayStringForContentType,
EncryptedItemInterface,
} from '@standardnotes/snjs'
import Button from '@/Components/Button/Button'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
type Props = { viewControllerManager: ViewControllerManager }
const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props) => {
const app = viewControllerManager.application
const [erroredItems, setErroredItems] = useState(app.items.invalidItems)
const getContentTypeDisplay = (item: EncryptedItemInterface): string => {
const display = DisplayStringForContentType(item.content_type)
if (display) {
return `${display[0].toUpperCase()}${display.slice(1)}`
} else {
return `Item of type ${item.content_type}`
}
}
const deleteItem = async (item: EncryptedItemInterface): Promise<void> => {
return deleteItems([item])
}
const deleteItems = async (items: EncryptedItemInterface[]): Promise<void> => {
const confirmed = await app.alertService.confirm(
`Are you sure you want to permanently delete ${items.length} item(s)?`,
undefined,
'Delete',
ButtonType.Danger,
)
if (!confirmed) {
return
}
void app.mutator.deleteItems(items)
setErroredItems(app.items.invalidItems)
}
const attemptDecryption = (item: EncryptedItemInterface): void => {
const errorOrTrue = app.canAttemptDecryptionOfItem(item)
if (errorOrTrue instanceof ClientDisplayableError) {
void app.alertService.showErrorAlert(errorOrTrue)
return
}
app.presentKeyRecoveryWizard()
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>
Error Decrypting Items <span className="ml-1 color-warning"></span>
</Title>
<Text>{`${erroredItems.length} items are errored and could not be decrypted.`}</Text>
<div className="flex">
<Button
className="min-w-20 mt-3 mr-2"
variant="normal"
label="Export all"
onClick={() => {
void app.getArchiveService().downloadEncryptedItems(erroredItems)
}}
/>
<Button
className="min-w-20 mt-3 mr-2"
variant="normal"
dangerStyle={true}
label="Delete all"
onClick={() => {
void deleteItems(erroredItems)
}}
/>
</div>
<HorizontalSeparator classes="mt-2.5 mb-3" />
{erroredItems.map((item, index) => {
return (
<Fragment key={item.uuid}>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>{`${getContentTypeDisplay(item)} created on ${item.createdAtString}`}</Subtitle>
<Text>
<div>Item ID: {item.uuid}</div>
<div>Last Modified: {item.updatedAtString}</div>
</Text>
<div className="flex">
<Button
className="min-w-20 mt-3 mr-2"
variant="normal"
label="Attempt decryption"
onClick={() => {
attemptDecryption(item)
}}
/>
<Button
className="min-w-20 mt-3 mr-2"
variant="normal"
label="Export"
onClick={() => {
void app.getArchiveService().downloadEncryptedItem(item)
}}
/>
<Button
className="min-w-20 mt-3 mr-2"
variant="normal"
dangerStyle={true}
label="Delete"
onClick={() => {
void deleteItem(item)
}}
/>
</div>
</div>
</div>
{index < erroredItems.length - 1 && <HorizontalSeparator classes="mt-2.5 mb-3" />}
</Fragment>
)
})}
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(ErroredItems)

View File

@@ -0,0 +1,263 @@
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 '@/Constants/Strings'
import { WebApplication } from '@/Application/Application'
import { preventRefreshing } from '@/Utils'
import { alertDialog } from '@/Services/AlertService'
import { ChangeEventHandler, FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import { ApplicationEvent } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { Title, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const PasscodeLock = ({ application, viewControllerManager }: Props) => {
const keyStorageInfo = StringUtils.keyStorageInfo(application)
const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions()
const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } =
viewControllerManager.accountMenuController
const passcodeInputRef = useRef<HTMLInputElement>(null)
const [passcode, setPasscode] = useState<string>()
const [passcodeConfirmation, setPasscodeConfirmation] = useState<string>()
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().catch(console.error)
}
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: ChangeEventHandler<HTMLInputElement> = (event) => {
const { value } = event.target
setPasscode(value)
}
const handleConfirmPasscodeChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const { value } = event.target
setPasscodeConfirmation(value)
}
const submitPasscodeForm = async (event: MouseEvent | FormEvent) => {
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().catch(console.error)
}, [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])
const cancelPasscodeForm = () => {
setShowPasscodeForm(false)
setPasscode(undefined)
setPasscodeConfirmation(undefined)
}
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Passcode Lock</Title>
{!hasPasscode && canAddPasscode && (
<>
<Text className="mb-3">Add a passcode to lock the application and encrypt on-device key storage.</Text>
{keyStorageInfo && <Text className="mb-3">{keyStorageInfo}</Text>}
{!showPasscodeForm && <Button label="Add passcode" onClick={handleAddPassCode} variant="primary" />}
</>
)}
{!hasPasscode && !canAddPasscode && (
<Text>
Adding a passcode is not supported in temporary sessions. Please sign out, then sign back in with the
"Stay signed in" option checked.
</Text>
)}
{showPasscodeForm && (
<form className="sk-panel-form" onSubmit={submitPasscodeForm}>
<div className="sk-panel-row" />
<input
className="sk-input contrast"
type="password"
ref={passcodeInputRef}
value={passcode ? passcode : ''}
onChange={handlePasscodeChange}
placeholder="Passcode"
/>
<input
className="sk-input contrast"
type="password"
value={passcodeConfirmation ? passcodeConfirmation : ''}
onChange={handleConfirmPasscodeChange}
placeholder="Confirm Passcode"
/>
<div className="min-h-2" />
<Button variant="primary" onClick={submitPasscodeForm} label="Set Passcode" className="mr-3" />
<Button variant="normal" onClick={cancelPasscodeForm} label="Cancel" />
</form>
)}
{hasPasscode && !showPasscodeForm && (
<>
<Text>Passcode lock is enabled.</Text>
<div className="flex flex-row mt-3">
<Button variant="normal" label="Change Passcode" onClick={changePasscodePressed} className="mr-3" />
<Button dangerStyle={true} label="Remove Passcode" onClick={removePasscodePressed} />
</div>
</>
)}
</PreferencesSegment>
</PreferencesGroup>
{hasPasscode && (
<>
<div className="min-h-3" />
<PreferencesGroup>
<PreferencesSegment>
<Title>Autolock</Title>
<Text className="mb-3">The autolock timer begins when the window or tab loses focus.</Text>
<div className="flex flex-row items-center">
{passcodeAutoLockOptions.map((option) => {
return (
<a
key={option.value}
className={`sk-a info mr-3 ${option.value === selectedAutoLockInterval ? 'boxed' : ''}`}
onClick={() => selectAutoLockInterval(option.value)}
>
{option.label}
</a>
)
})}
</div>
</PreferencesSegment>
</PreferencesGroup>
</>
)}
</>
)
}
export default observer(PasscodeLock)

View File

@@ -0,0 +1,141 @@
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Switch from '@/Components/Switch/Switch'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { MuteSignInEmailsOption, LogSessionUserAgentOption, SettingName } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Constants/Strings'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
type Props = {
application: WebApplication
}
const Privacy: FunctionComponent<Props> = ({ application }: Props) => {
const [signInEmailsMutedValue, setSignInEmailsMutedValue] = useState<MuteSignInEmailsOption>(
MuteSignInEmailsOption.NotMuted,
)
const [sessionUaLoggingValue, setSessionUaLoggingValue] = useState<LogSessionUserAgentOption>(
LogSessionUserAgentOption.Enabled,
)
const [isLoading, setIsLoading] = useState(true)
const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
try {
await application.settings.updateSetting(settingName, payload, false)
return true
} catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
return false
}
}
const loadSettings = useCallback(async () => {
if (!application.getUser()) {
return
}
setIsLoading(true)
try {
const userSettings = await application.settings.listSettings()
setSignInEmailsMutedValue(
userSettings.getSettingValue<MuteSignInEmailsOption>(
SettingName.MuteSignInEmails,
MuteSignInEmailsOption.NotMuted,
),
)
setSessionUaLoggingValue(
userSettings.getSettingValue<LogSessionUserAgentOption>(
SettingName.LogSessionUserAgent,
LogSessionUserAgentOption.Enabled,
),
)
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
}
}, [application])
useEffect(() => {
loadSettings().catch(console.error)
}, [loadSettings])
const toggleMuteSignInEmails = async () => {
const previousValue = signInEmailsMutedValue
const newValue =
previousValue === MuteSignInEmailsOption.Muted ? MuteSignInEmailsOption.NotMuted : MuteSignInEmailsOption.Muted
setSignInEmailsMutedValue(newValue)
const updateResult = await updateSetting(SettingName.MuteSignInEmails, newValue)
if (!updateResult) {
setSignInEmailsMutedValue(previousValue)
}
}
const toggleSessionLogging = async () => {
const previousValue = sessionUaLoggingValue
const newValue =
previousValue === LogSessionUserAgentOption.Enabled
? LogSessionUserAgentOption.Disabled
: LogSessionUserAgentOption.Enabled
setSessionUaLoggingValue(newValue)
const updateResult = await updateSetting(SettingName.LogSessionUserAgent, newValue)
if (!updateResult) {
setSessionUaLoggingValue(previousValue)
}
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Privacy</Title>
<div>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Disable sign-in notification emails</Subtitle>
<Text>
Disables email notifications when a new sign-in occurs on your account. (Email notifications are
available to paid subscribers).
</Text>
</div>
{isLoading ? (
<div className={'sk-spinner info small flex-shrink-0 ml-2'} />
) : (
<Switch
onChange={toggleMuteSignInEmails}
checked={signInEmailsMutedValue === MuteSignInEmailsOption.Muted}
/>
)}
</div>
<HorizontalSeparator classes="my-4" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Session user agent logging</Subtitle>
<Text>
User agent logging allows you to identify the devices or browsers signed into your account. For
increased privacy, you can disable this feature, which will remove all saved user agent values from our
server, and disable future logging of this value.
</Text>
</div>
{isLoading ? (
<div className={'sk-spinner info small flex-shrink-0 ml-2'} />
) : (
<Switch
onChange={toggleSessionLogging}
checked={sessionUaLoggingValue === LogSessionUserAgentOption.Enabled}
/>
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Privacy)

View File

@@ -0,0 +1,93 @@
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useCallback, useState, useEffect } from 'react'
import { ApplicationEvent } from '@standardnotes/snjs'
import { isSameDay } from '@/Utils'
import Button from '@/Components/Button/Button'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import { Title, Text } from '../../PreferencesComponents/Content'
type Props = {
application: WebApplication
}
const Protections: FunctionComponent<Props> = ({ application }) => {
const enableProtections = () => {
application.clearProtectionSession().catch(console.error)
}
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 removeUnprotectedSessionBeginObserver = application.addEventObserver(async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil())
}, ApplicationEvent.UnprotectedSessionBegan)
const removeUnprotectedSessionEndObserver = application.addEventObserver(async () => {
setProtectionsDisabledUntil(getProtectionsDisabledUntil())
}, ApplicationEvent.UnprotectedSessionExpired)
const removeKeyStatusChangedObserver = application.addEventObserver(async () => {
setHasProtections(application.hasProtectionSources())
}, ApplicationEvent.KeyStatusChanged)
return () => {
removeUnprotectedSessionBeginObserver()
removeUnprotectedSessionEndObserver()
removeKeyStatusChangedObserver()
}
}, [application, getProtectionsDisabledUntil])
if (!hasProtections) {
return null
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Protections</Title>
{protectionsDisabledUntil ? (
<Text className="info">Unprotected access expires at {protectionsDisabledUntil}.</Text>
) : (
<Text className="info">Protections are enabled.</Text>
)}
<Text className="mt-2">
Actions like viewing or searching protected notes, exporting decrypted backups, or revoking an active session
require additional authentication such as entering your account password or application passcode.
</Text>
{protectionsDisabledUntil && (
<Button className="mt-3" variant="primary" label="End Unprotected Access" onClick={enableProtections} />
)}
</PreferencesSegment>
</PreferencesGroup>
)
}
export default Protections

View File

@@ -0,0 +1,31 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FunctionComponent } from 'react'
import TwoFactorAuthWrapper from '../TwoFactorAuth/TwoFactorAuthWrapper'
import { MfaProps } from '../TwoFactorAuth/MfaProps'
import Encryption from './Encryption'
import PasscodeLock from './PasscodeLock'
import Privacy from './Privacy'
import Protections from './Protections'
import ErroredItems from './ErroredItems'
import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane'
interface SecurityProps extends MfaProps {
viewControllerManager: ViewControllerManager
application: WebApplication
}
const Security: FunctionComponent<SecurityProps> = (props) => (
<PreferencesPane>
<Encryption viewControllerManager={props.viewControllerManager} />
{props.application.items.invalidItems.length > 0 && (
<ErroredItems viewControllerManager={props.viewControllerManager} />
)}
<Protections application={props.application} />
<TwoFactorAuthWrapper mfaProvider={props.mfaProvider} userProvider={props.userProvider} />
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
{props.application.getUser() && <Privacy application={props.application} />}
</PreferencesPane>
)
export default Security

View File

@@ -0,0 +1 @@
export const formatCount = (count: number, itemType: string) => `${count} / ${count} ${itemType}`

View File

@@ -0,0 +1,5 @@
import { WebApplication } from '@/Application/Application'
export const securityPrefsHasBubble = (application: WebApplication): boolean => {
return application.items.invalidItems.length > 0
}

View File

@@ -0,0 +1,66 @@
import Icon from '@/Components/Icon/Icon'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { FunctionComponent, useState, useRef, useEffect, MouseEventHandler } from 'react'
import { IconType } from '@standardnotes/snjs'
type Props = {
className?: string
icon: IconType
onMouseEnter?: MouseEventHandler<HTMLButtonElement>
onMouseLeave?: MouseEventHandler<HTMLButtonElement>
}
const DisclosureIconButton: FunctionComponent<Props> = ({ className = '', icon, onMouseEnter, onMouseLeave }) => (
<DisclosureButton
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${className ?? ''}`}
>
<Icon type={icon} />
</DisclosureButton>
)
/**
* AuthAppInfoPopup is an info icon that shows a tooltip when clicked
* Tooltip is dismissible by clicking outside
*
* Note: it can be generalized but more use cases are required
* @returns
*/
const AuthAppInfoTooltip: FunctionComponent = () => {
const [isClicked, setClicked] = useState(false)
const [isHover, setHover] = useState(false)
const ref = useRef(null)
useEffect(() => {
const dismiss = () => setClicked(false)
document.addEventListener('mousedown', dismiss)
return () => {
document.removeEventListener('mousedown', dismiss)
}
}, [ref])
return (
<Disclosure open={isClicked || isHover} onChange={() => setClicked(!isClicked)}>
<div className="relative">
<DisclosureIconButton
icon="info"
className="mt-1"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
/>
<DisclosurePanel>
<div
className={`bg-inverted-default color-inverted-default text-center rounded shadow-overlay
py-1.5 px-2 absolute w-103 -top-10 -left-51`}
>
Some apps, like Google Authenticator, do not back up and restore your secret keys if you lose your device or
get a new one.
</div>
</DisclosurePanel>
</div>
</Disclosure>
)
}
export default AuthAppInfoTooltip

View File

@@ -0,0 +1,11 @@
import { FunctionComponent } from 'react'
type Props = {
className?: string
}
const Bullet: FunctionComponent<Props> = ({ className = '' }) => (
<div className={`min-w-1 min-h-1 rounded-full bg-inverted-default ${className} mr-2`} />
)
export default Bullet

View File

@@ -0,0 +1,24 @@
import { FunctionComponent, useState } from 'react'
import IconButton from '@/Components/Button/IconButton'
type Props = {
copyValue: string
}
const CopyButton: FunctionComponent<Props> = ({ copyValue: secretKey }) => {
const [isCopied, setCopied] = useState(false)
return (
<IconButton
focusable={false}
title="Copy to clipboard"
icon={isCopied ? 'check' : 'copy'}
className={`${isCopied ? 'success' : undefined} p-0`}
onClick={() => {
navigator?.clipboard?.writeText(secretKey).catch(console.error)
setCopied(() => true)
}}
/>
)
}
export default CopyButton

View File

@@ -0,0 +1,6 @@
import { MfaProvider, UserProvider } from '@/Components/Preferences/Providers'
export interface MfaProps {
userProvider: UserProvider
mfaProvider: MfaProvider
}

View File

@@ -0,0 +1,86 @@
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import IconButton from '@/Components/Button/IconButton'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import CopyButton from './CopyButton'
import Bullet from './Bullet'
import { downloadSecretKey } from './download-secret-key'
import { TwoFactorActivation } from './TwoFactorActivation'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
type Props = {
activation: TwoFactorActivation
}
const SaveSecretKey: FunctionComponent<Props> = ({ activation: act }) => {
const download = (
<IconButton
focusable={false}
title="Download"
icon="download"
className="p-0"
onClick={() => {
downloadSecretKey(act.secretKey)
}}
/>
)
return (
<ModalDialog>
<ModalDialogLabel
closeDialog={() => {
act.cancelActivation()
}}
>
Step 2 of 3 - Save secret key
</ModalDialogLabel>
<ModalDialogDescription className="h-33">
<div className="flex-grow flex flex-col">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
<b>Save your secret key</b>{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
somewhere safe
</a>
:
</div>
<div className="min-w-2" />
<DecoratedInput
disabled={true}
right={[<CopyButton copyValue={act.secretKey} />, download]}
value={act.secretKey}
/>
</div>
<div className="h-2" />
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
You can use this key to generate codes if you lose access to your authenticator app.{' '}
<a
target="_blank"
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
>
Learn more
</a>
</div>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="min-w-20" variant="normal" label="Back" onClick={() => act.openScanQRCode()} />
<Button className="min-w-20" variant="primary" label="Next" onClick={() => act.openVerification()} />
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(SaveSecretKey)

View File

@@ -0,0 +1,63 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import QRCode from 'qrcode.react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { TwoFactorActivation } from './TwoFactorActivation'
import AuthAppInfoTooltip from './AuthAppInfoPopup'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import CopyButton from './CopyButton'
import Bullet from './Bullet'
type Props = {
activation: TwoFactorActivation
}
const ScanQRCode: FunctionComponent<Props> = ({ activation: act }) => {
return (
<ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}>Step 1 of 3 - Scan QR code</ModalDialogLabel>
<ModalDialogDescription className="h-33">
<div className="w-25 h-25 flex items-center justify-center bg-info">
<QRCode className="border-neutral-contrast-bg border-solid border-2" value={act.qrCode} size={100} />
</div>
<div className="min-w-5" />
<div className="flex-grow flex flex-col">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Open your <b>authenticator app</b>.
</div>
<div className="min-w-2" />
<AuthAppInfoTooltip />
</div>
<div className="min-h-2" />
<div className="flex flex-row items-center">
<Bullet className="self-start mt-2" />
<div className="min-w-1" />
<div className="text-sm flex-grow">
<b>Scan this QR code</b> or <b>add this secret key</b>:
</div>
</div>
<div className="min-h-2" />
<DecoratedInput
className="ml-4 w-92"
disabled={true}
value={act.secretKey}
right={[<CopyButton copyValue={act.secretKey} />]}
/>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="min-w-20" variant="normal" label="Cancel" onClick={() => act.cancelActivation()} />
<Button className="min-w-20" variant="primary" label="Next" onClick={() => act.openSaveSecretKey()} />
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(ScanQRCode)

View File

@@ -0,0 +1,125 @@
import { MfaProvider } from '@/Components/Preferences/Providers'
import { action, makeAutoObservable, observable } from 'mobx'
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification' | 'success'
type VerificationStatus = 'none' | 'invalid-auth-code' | 'invalid-secret' | 'valid'
export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const
private _activationStep: ActivationStep
private _2FAVerification: VerificationStatus = 'none'
private inputSecretKey = ''
private inputOtpToken = ''
constructor(
private mfaProvider: MfaProvider,
private readonly email: string,
private readonly _secretKey: string,
private _cancelActivation: () => void,
private _enabled2FA: () => void,
) {
this._activationStep = 'scan-qr-code'
makeAutoObservable<
TwoFactorActivation,
'_secretKey' | '_authCode' | '_step' | '_enable2FAVerification' | 'inputOtpToken' | 'inputSecretKey'
>(
this,
{
_secretKey: observable,
_authCode: observable,
_step: observable,
_enable2FAVerification: observable,
inputOtpToken: observable,
inputSecretKey: observable,
},
{ autoBind: true },
)
}
get secretKey(): string {
return this._secretKey
}
get activationStep(): ActivationStep {
return this._activationStep
}
get verificationStatus(): VerificationStatus {
return this._2FAVerification
}
get qrCode(): string {
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${this.email}`
}
cancelActivation(): void {
this._cancelActivation()
}
openScanQRCode(): void {
if (this._activationStep === 'save-secret-key') {
this._activationStep = 'scan-qr-code'
}
}
openSaveSecretKey(): void {
const preconditions: ActivationStep[] = ['scan-qr-code', 'verification']
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'save-secret-key'
}
}
openVerification(): void {
this.inputOtpToken = ''
this.inputSecretKey = ''
if (this._activationStep === 'save-secret-key') {
this._activationStep = 'verification'
this._2FAVerification = 'none'
}
}
openSuccess(): void {
if (this._activationStep === 'verification') {
this._activationStep = 'success'
}
}
setInputSecretKey(secretKey: string): void {
this.inputSecretKey = secretKey
}
setInputOtpToken(otpToken: string): void {
this.inputOtpToken = otpToken
}
enable2FA(): void {
if (this.inputSecretKey !== this._secretKey) {
this._2FAVerification = 'invalid-secret'
return
}
this.mfaProvider
.enableMfa(this.inputSecretKey, this.inputOtpToken)
.then(
action(() => {
this._2FAVerification = 'valid'
this.openSuccess()
}),
)
.catch(
action(() => {
this._2FAVerification = 'invalid-auth-code'
}),
)
}
finishActivation(): void {
if (this._activationStep === 'success') {
this._enabled2FA()
}
}
}

View File

@@ -0,0 +1,26 @@
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { TwoFactorActivation } from './TwoFactorActivation'
import SaveSecretKey from './SaveSecretKey'
import ScanQRCode from './ScanQRCode'
import Verification from './Verification'
import TwoFactorSuccess from './TwoFactorSuccess'
type Props = {
activation: TwoFactorActivation
}
const TwoFactorActivationView: FunctionComponent<Props> = ({ activation: act }) => {
switch (act.activationStep) {
case 'scan-qr-code':
return <ScanQRCode activation={act} />
case 'save-secret-key':
return <SaveSecretKey activation={act} />
case 'verification':
return <Verification activation={act} />
case 'success':
return <TwoFactorSuccess activation={act} />
}
}
export default observer(TwoFactorActivationView)

View File

@@ -0,0 +1,137 @@
import { MfaProvider, UserProvider } from '@/Components/Preferences/Providers'
import { action, makeAutoObservable, observable } from 'mobx'
import { TwoFactorActivation } from './TwoFactorActivation'
type TwoFactorStatus = 'two-factor-enabled' | TwoFactorActivation | 'two-factor-disabled'
export const is2FADisabled = (status: TwoFactorStatus): status is 'two-factor-disabled' =>
status === 'two-factor-disabled'
export const is2FAActivation = (status: TwoFactorStatus): status is TwoFactorActivation =>
(status as TwoFactorActivation)?.type === 'two-factor-activation'
export const is2FAEnabled = (status: TwoFactorStatus): status is 'two-factor-enabled' => status === 'two-factor-enabled'
export class TwoFactorAuth {
private _status: TwoFactorStatus | 'fetching' = 'fetching'
private _errorMessage: string | null
constructor(private readonly mfaProvider: MfaProvider, private readonly userProvider: UserProvider) {
this._errorMessage = null
makeAutoObservable<TwoFactorAuth, '_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation'>(
this,
{
_status: observable,
_errorMessage: observable,
deactivateMfa: action,
startActivation: action,
},
{ autoBind: true },
)
}
private startActivation(): void {
const setDisabled = action(() => (this._status = 'two-factor-disabled'))
const setEnabled = action(() => {
this._status = 'two-factor-enabled'
this.fetchStatus()
})
this.mfaProvider
.generateMfaSecret()
.then(
action((secret) => {
this._status = new TwoFactorActivation(
this.mfaProvider,
this.userProvider.getUser()?.email as string,
secret,
setDisabled,
setEnabled,
)
}),
)
.catch(
action((e) => {
this.setError(e.message)
}),
)
}
private deactivate2FA(): void {
this.mfaProvider
.disableMfa()
.then(
action(() => {
this.fetchStatus()
}),
)
.catch(
action((e) => {
this.setError(e.message)
}),
)
}
isLoggedIn(): boolean {
return this.userProvider.getUser() != undefined
}
fetchStatus(): void {
if (!this.isLoggedIn()) {
return
}
if (!this.isMfaFeatureAvailable()) {
return
}
this.mfaProvider
.isMfaActivated()
.then(
action((active) => {
this._status = active ? 'two-factor-enabled' : 'two-factor-disabled'
this.setError(null)
}),
)
.catch(
action((e) => {
this._status = 'two-factor-disabled'
this.setError(e.message)
}),
)
}
private setError(errorMessage: string | null): void {
this._errorMessage = errorMessage
}
toggle2FA(): void {
if (!this.isLoggedIn()) {
return
}
if (!this.isMfaFeatureAvailable()) {
return
}
if (this._status === 'two-factor-disabled') {
return this.startActivation()
}
if (this._status === 'two-factor-enabled') {
return this.deactivate2FA()
}
}
get errorMessage(): string | null {
return this._errorMessage
}
get status(): TwoFactorStatus | 'fetching' {
return this._status
}
isMfaFeatureAvailable(): boolean {
return this.mfaProvider.isMfaFeatureAvailable()
}
}

View File

@@ -0,0 +1,45 @@
import { FunctionComponent } from 'react'
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { is2FAActivation, TwoFactorAuth } from '../TwoFactorAuth'
import TwoFactorActivationView from '../TwoFactorActivationView'
import TwoFactorTitle from './TwoFactorTitle'
import TwoFactorDescription from './TwoFactorDescription'
import TwoFactorSwitch from './TwoFactorSwitch'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
type Props = {
auth: TwoFactorAuth
}
const TwoFactorAuthView: FunctionComponent<Props> = ({ auth }) => {
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<TwoFactorTitle auth={auth} />
<TwoFactorDescription auth={auth} />
</div>
<div className="flex flex-col justify-center items-center min-w-15">
<TwoFactorSwitch auth={auth} />
</div>
</div>
</PreferencesSegment>
{auth.errorMessage != null && (
<PreferencesSegment>
<Text className="color-danger">{auth.errorMessage}</Text>
</PreferencesSegment>
)}
</PreferencesGroup>
{auth.status !== 'fetching' && is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />
)}
</>
)
}
export default observer(TwoFactorAuthView)

View File

@@ -0,0 +1,28 @@
import { FunctionComponent } from 'react'
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { TwoFactorAuth } from '../TwoFactorAuth'
type Props = {
auth: TwoFactorAuth
}
const TwoFactorDescription: FunctionComponent<Props> = ({ auth }) => {
if (!auth.isLoggedIn()) {
return <Text>Sign in or register for an account to configure 2FA.</Text>
}
if (!auth.isMfaFeatureAvailable()) {
return (
<Text>
A paid subscription plan is required to enable 2FA.{' '}
<a target="_blank" href="https://standardnotes.com/features">
Learn more
</a>
.
</Text>
)
}
return <Text>An extra layer of security when logging in to your account.</Text>
}
export default observer(TwoFactorDescription)

View File

@@ -0,0 +1,22 @@
import { FunctionComponent } from 'react'
import Switch from '@/Components/Switch/Switch'
import { observer } from 'mobx-react-lite'
import { is2FADisabled, TwoFactorAuth } from '../TwoFactorAuth'
type Props = {
auth: TwoFactorAuth
}
const TwoFactorSwitch: FunctionComponent<Props> = ({ auth }) => {
if (!(auth.isLoggedIn() && auth.isMfaFeatureAvailable())) {
return null
}
if (auth.status === 'fetching') {
return <div className="sk-spinner normal info" />
}
return <Switch checked={!is2FADisabled(auth.status)} onChange={auth.toggle2FA} />
}
export default observer(TwoFactorSwitch)

View File

@@ -0,0 +1,20 @@
import { FunctionComponent } from 'react'
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { TwoFactorAuth } from '../TwoFactorAuth'
type Props = {
auth: TwoFactorAuth
}
const TwoFactorTitle: FunctionComponent<Props> = ({ auth }) => {
if (!auth.isLoggedIn()) {
return <Title>Two-factor authentication not available</Title>
}
if (!auth.isMfaFeatureAvailable()) {
return <Title>Two-factor authentication not available</Title>
}
return <Title>Two-factor authentication</Title>
}
export default observer(TwoFactorTitle)

View File

@@ -0,0 +1,12 @@
import { FunctionComponent, useState } from 'react'
import { MfaProps } from './MfaProps'
import { TwoFactorAuth } from './TwoFactorAuth'
import TwoFactorAuthView from './TwoFactorAuthView/TwoFactorAuthView'
const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
const [auth] = useState(() => new TwoFactorAuth(props.mfaProvider, props.userProvider))
auth.fetchStatus()
return <TwoFactorAuthView auth={auth} />
}
export default TwoFactorAuthWrapper

View File

@@ -0,0 +1,29 @@
import Button from '@/Components/Button/Button'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { TwoFactorActivation } from './TwoFactorActivation'
type Props = {
activation: TwoFactorActivation
}
const TwoFactorSuccess: FunctionComponent<Props> = ({ activation: act }) => (
<ModalDialog>
<ModalDialogLabel closeDialog={act.finishActivation}>Successfully Enabled</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-row items-center justify-center pt-2">
<Subtitle>Two-factor authentication has been successfully enabled for your account.</Subtitle>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="min-w-20" variant="primary" label="Finish" onClick={act.finishActivation} />
</ModalDialogButtons>
</ModalDialog>
)
export default observer(TwoFactorSuccess)

View File

@@ -0,0 +1,58 @@
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import Bullet from './Bullet'
import { TwoFactorActivation } from './TwoFactorActivation'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
type Props = {
activation: TwoFactorActivation
}
const Verification: FunctionComponent<Props> = ({ activation: act }) => {
const secretKeyClass = act.verificationStatus === 'invalid-secret' ? 'border-danger' : ''
const authTokenClass = act.verificationStatus === 'invalid-auth-code' ? 'border-danger' : ''
return (
<ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}>Step 3 of 3 - Verification</ModalDialogLabel>
<ModalDialogDescription className="h-33">
<div className="flex-grow flex flex-col">
<div className="flex flex-row items-center mb-4">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Enter your <b>secret key</b>:
</div>
<div className="min-w-2" />
<DecoratedInput className={`w-92 ${secretKeyClass}`} onChange={act.setInputSecretKey} />
</div>
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Verify the <b>authentication code</b> generated by your authenticator app:
</div>
<div className="min-w-2" />
<DecoratedInput className={`w-30 ${authTokenClass}`} onChange={act.setInputOtpToken} />
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
{act.verificationStatus === 'invalid-auth-code' && (
<div className="text-sm color-danger flex-grow">Incorrect authentication code, please try again.</div>
)}
{act.verificationStatus === 'invalid-secret' && (
<div className="text-sm color-danger flex-grow">Incorrect secret key, please try again.</div>
)}
<Button className="min-w-20" variant="normal" label="Back" onClick={act.openSaveSecretKey} />
<Button className="min-w-20" variant="primary" label="Next" onClick={act.enable2FA} />
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(Verification)

View File

@@ -0,0 +1,13 @@
// Temporary implementation until integration
export function downloadSecretKey(text: string) {
const link = document.createElement('a')
const blob = new Blob([text], {
type: 'text/plain;charset=utf-8',
})
link.href = window.URL.createObjectURL(blob)
link.setAttribute('download', 'standardnotes_2fa_key.txt')
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(link.href)
}

View File

@@ -0,0 +1,15 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import PreferencesMenuView from './PreferencesMenuView'
import PaneSelector from './PaneSelector'
import { PreferencesProps } from './PreferencesProps'
const PreferencesCanvas: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = (props) => (
<div className="flex flex-row flex-grow min-h-0 justify-between">
<PreferencesMenuView menu={props.menu} />
<PaneSelector {...props} />
</div>
)
export default observer(PreferencesCanvas)

View File

@@ -0,0 +1,35 @@
import { FunctionComponent } from 'react'
export const Title: FunctionComponent = ({ children }) => (
<>
<h2 className="text-base m-0 mb-1 info">{children}</h2>
<div className="min-h-2" />
</>
)
export const Subtitle: FunctionComponent<{ className?: string }> = ({ children, className = '' }) => (
<h4 className={`font-medium text-sm m-0 mb-1 ${className}`}>{children}</h4>
)
export const SubtitleLight: FunctionComponent<{ className?: string }> = ({ children, className = '' }) => (
<h4 className={`font-normal text-sm m-0 mb-1 ${className}`}>{children}</h4>
)
export const Text: FunctionComponent<{ className?: string }> = ({ children, className = '' }) => (
<p className={`${className} text-xs`}>{children}</p>
)
const buttonClasses =
'block bg-default color-text rounded border-solid \
border-1 px-4 py-1.75 font-bold text-sm fit-content \
focus:bg-contrast hover:bg-contrast border-main'
export const LinkButton: FunctionComponent<{
label: string
link: string
className?: string
}> = ({ label, link, className }) => (
<a target="_blank" className={`${className} ${buttonClasses}`} href={link}>
{label}
</a>
)

View File

@@ -0,0 +1,28 @@
import Icon from '@/Components/Icon/Icon'
import { FunctionComponent } from 'react'
import { IconType } from '@standardnotes/snjs'
interface Props {
iconType: IconType
label: string
selected: boolean
hasBubble?: boolean
onClick: () => void
}
const PreferencesMenuItem: FunctionComponent<Props> = ({ iconType, label, selected, onClick, hasBubble }) => (
<div
className={`preferences-menu-item select-none ${selected ? 'selected' : ''}`}
onClick={(e) => {
e.preventDefault()
onClick()
}}
>
<Icon className="icon" type={iconType} />
<div className="min-w-1" />
{label}
{hasBubble && <span className="ml-1 color-warning"></span>}
</div>
)
export default PreferencesMenuItem

View File

@@ -0,0 +1,7 @@
import { FunctionComponent } from 'react'
const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-main px-6 py-6 flex flex-col mb-3">{children}</div>
)
export default PreferencesGroup

View File

@@ -0,0 +1,14 @@
import { FunctionComponent } from 'react'
const PreferencesPane: FunctionComponent = ({ children }) => (
<div className="color-foreground flex-grow flex flex-row overflow-y-auto min-h-0">
<div className="flex-grow flex flex-col py-6 items-center">
<div className="w-125 max-w-125 flex flex-col">
{children != undefined && Array.isArray(children) ? children.filter((child) => child != undefined) : children}
</div>
</div>
<div className="flex-basis-55 flex-shrink" />
</div>
)
export default PreferencesPane

View File

@@ -0,0 +1,10 @@
import { FunctionComponent } from 'react'
type Props = {
classes?: string
}
const PreferencesSegment: FunctionComponent<Props> = ({ children, classes = '' }) => (
<div className={`flex flex-col ${classes}`}>{children}</div>
)
export default PreferencesSegment

View File

@@ -0,0 +1,131 @@
import { action, makeAutoObservable, observable } from 'mobx'
import { IconType } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import { ExtensionsLatestVersions } from './Panes/Extensions/ExtensionsLatestVersions'
import { securityPrefsHasBubble } from './Panes/Security/securityPrefsHasBubble'
const PREFERENCE_IDS = [
'general',
'account',
'security',
'appearance',
'backups',
'listed',
'shortcuts',
'accessibility',
'get-free-month',
'help-feedback',
] as const
export type PreferenceId = typeof PREFERENCE_IDS[number]
interface PreferencesMenuItem {
readonly id: PreferenceId
readonly icon: IconType
readonly label: string
readonly hasBubble?: boolean
}
interface SelectableMenuItem extends PreferencesMenuItem {
selected: boolean
}
/**
* Items are in order of appearance
*/
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
{ id: 'get-free-month', label: 'Get a free month', icon: 'star' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
]
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
]
export class PreferencesMenu {
private _selectedPane: PreferenceId = 'account'
private _menu: PreferencesMenuItem[]
private _extensionLatestVersions: ExtensionsLatestVersions = new ExtensionsLatestVersions(new Map())
constructor(private application: WebApplication, private readonly _enableUnfinishedFeatures: boolean) {
this._menu = this._enableUnfinishedFeatures ? PREFERENCES_MENU_ITEMS : READY_PREFERENCES_MENU_ITEMS
this.loadLatestVersions()
makeAutoObservable<
PreferencesMenu,
'_selectedPane' | '_twoFactorAuth' | '_extensionPanes' | '_extensionLatestVersions' | 'loadLatestVersions'
>(this, {
_twoFactorAuth: observable,
_selectedPane: observable,
_extensionPanes: observable.ref,
_extensionLatestVersions: observable.ref,
loadLatestVersions: action,
})
}
private loadLatestVersions(): void {
ExtensionsLatestVersions.load(this.application)
.then((versions) => {
if (versions) {
this._extensionLatestVersions = versions
}
})
.catch(console.error)
}
get extensionsLatestVersions(): ExtensionsLatestVersions {
return this._extensionLatestVersions
}
get menuItems(): SelectableMenuItem[] {
const menuItems = this._menu.map((preference) => {
const item: SelectableMenuItem = {
...preference,
selected: preference.id === this._selectedPane,
hasBubble: this.sectionHasBubble(preference.id),
}
return item
})
return menuItems
}
get selectedMenuItem(): PreferencesMenuItem | undefined {
return this._menu.find((item) => item.id === this._selectedPane)
}
get selectedPaneId(): PreferenceId {
if (this.selectedMenuItem != undefined) {
return this.selectedMenuItem.id
}
return 'account'
}
selectPane(key: PreferenceId): void {
this._selectedPane = key
}
sectionHasBubble(id: PreferenceId): boolean {
if (id === 'security') {
return securityPrefsHasBubble(this.application)
}
return false
}
}

View File

@@ -0,0 +1,25 @@
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import PreferencesMenuItem from './PreferencesComponents/MenuItem'
import { PreferencesMenu } from './PreferencesMenu'
type Props = {
menu: PreferencesMenu
}
const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => (
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
{menu.menuItems.map((pref) => (
<PreferencesMenuItem
key={pref.id}
iconType={pref.icon}
label={pref.label}
selected={pref.selected}
hasBubble={pref.hasBubble}
onClick={() => menu.selectPane(pref.id)}
/>
))}
</div>
)
export default observer(PreferencesMenuView)

View File

@@ -0,0 +1,9 @@
import { WebApplication } from '@/Application/Application'
import { MfaProps } from './Panes/TwoFactorAuth/MfaProps'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
export interface PreferencesProps extends MfaProps {
application: WebApplication
viewControllerManager: ViewControllerManager
closePreferences: () => void
}

View File

@@ -0,0 +1,48 @@
import RoundIconButton from '@/Components/Button/RoundIconButton'
import TitleBar from '@/Components/TitleBar/TitleBar'
import Title from '@/Components/TitleBar/Title'
import { FunctionComponent, useEffect, useMemo } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import PreferencesCanvas from './PreferencesCanvas'
import { PreferencesProps } from './PreferencesProps'
const PreferencesView: FunctionComponent<PreferencesProps> = (props) => {
const menu = useMemo(
() => new PreferencesMenu(props.application, props.viewControllerManager.enableUnfinishedFeatures),
[props.viewControllerManager.enableUnfinishedFeatures, props.application],
)
useEffect(() => {
menu.selectPane(props.viewControllerManager.preferencesController.currentPane)
const removeEscKeyObserver = props.application.io.addKeyObserver({
key: 'Escape',
onKeyDown: (event) => {
event.preventDefault()
props.closePreferences()
},
})
return () => {
removeEscKeyObserver()
}
}, [props, menu])
return (
<div className="h-full w-full absolute top-left-0 flex flex-col bg-contrast z-index-preferences">
<TitleBar className="items-center justify-between">
<div className="h-8 w-8" />
<Title className="text-lg">Your preferences for Standard Notes</Title>
<RoundIconButton
onClick={() => {
props.closePreferences()
}}
type="normal"
icon="close"
/>
</TitleBar>
<PreferencesCanvas {...props} menu={menu} />
</div>
)
}
export default observer(PreferencesView)

View File

@@ -0,0 +1,25 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import PreferencesView from './PreferencesView'
import { PreferencesViewWrapperProps } from './PreferencesViewWrapperProps'
const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperProps> = ({
viewControllerManager,
application,
}) => {
if (!viewControllerManager.preferencesController?.isOpen) {
return null
}
return (
<PreferencesView
closePreferences={() => viewControllerManager.preferencesController.closePreferences()}
application={application}
viewControllerManager={viewControllerManager}
mfaProvider={application}
userProvider={application}
/>
)
}
export default observer(PreferencesViewWrapper)

View File

@@ -0,0 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
export interface PreferencesViewWrapperProps {
viewControllerManager: ViewControllerManager
application: WebApplication
}

View File

@@ -0,0 +1,13 @@
export interface MfaProvider {
isMfaActivated(): Promise<boolean>
generateMfaSecret(): Promise<string>
getOtpToken(secret: string): Promise<string>
enableMfa(secret: string, otpToken: string): Promise<void>
disableMfa(): Promise<void>
isMfaFeatureAvailable(): boolean
}

View File

@@ -0,0 +1,3 @@
export interface UserProvider {
getUser(): { uuid: string; email: string } | undefined
}

View File

@@ -0,0 +1,2 @@
export * from './MfaProvider'
export * from './UserProvider'