feat: New one-click Home Server, now in Labs. Launch your own self-hosted server instance with just 1 click from the Preferences window. (#2341)

This commit is contained in:
Mo
2023-07-03 08:30:48 -05:00
committed by GitHub
parent d79e7b14b1
commit 96f42643a9
367 changed files with 5895 additions and 570 deletions

View File

@@ -48,6 +48,13 @@ export class DesktopManager
void this.backups.importWatchedDirectoryChanges(changes)
}
async handleHomeServerStarted(serverUrl: string): Promise<void> {
const userIsSignedIn = this.application.sessions.isSignedIn()
if (!userIsSignedIn) {
await this.application.setCustomHost(serverUrl)
}
}
beginTextBackupsTimer() {
if (this.textBackupsInterval) {
clearInterval(this.textBackupsInterval)

View File

@@ -10,7 +10,11 @@ export {
FileBackupReadToken,
FileBackupReadChunkResponse,
FileDownloadProgress,
HomeServerManagerInterface,
HomeServerStatus,
PlaintextBackupsMapping,
DesktopWatchedDirectoriesChanges,
DesktopWatchedDirectoriesChange,
HomeServerEnvironmentConfiguration,
DirectoryManagerInterface,
} from '@standardnotes/snjs'

View File

@@ -12,6 +12,9 @@ import {
GetSortedPayloadsByPriority,
DatabaseFullEntryLoadChunk,
DatabaseFullEntryLoadChunkResponse,
ApplicationInterface,
namespacedKey,
RawStorageKey,
} from '@standardnotes/snjs'
import { Database } from '../Database'
@@ -30,6 +33,12 @@ export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface
this.databases.push(database)
}
removeApplication(application: ApplicationInterface): void {
const database = this.databaseForIdentifier(application.identifier)
database.deinit()
this.databases = this.databases.filter((db) => db !== database)
}
public async getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined> {
const value = await this.getRawStorageValue(key)
if (value == undefined) {
@@ -87,6 +96,11 @@ export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface
localStorage.clear()
}
async removeRawStorageValuesForIdentifier(identifier: ApplicationIdentifier) {
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion))
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.StorageObject))
}
async openDatabase(identifier: ApplicationIdentifier) {
this.databaseForIdentifier(identifier).unlock()
return new Promise((resolve, reject) => {

View File

@@ -24,7 +24,7 @@ describe('web application', () => {
SNLog.onLog = console.log
SNLog.onError = console.error
beforeEach(() => {
beforeEach(async () => {
const identifier = '123'
window.matchMedia = jest.fn().mockReturnValue({ matches: false, addListener: jest.fn() })
@@ -34,7 +34,7 @@ describe('web application', () => {
appVersion: '1.2.3',
setApplication: jest.fn(),
openDatabase: jest.fn().mockReturnValue(Promise.resolve()),
getRawStorageValue: jest.fn().mockImplementation((key) => {
getRawStorageValue: jest.fn().mockImplementation(async (key) => {
if (key === namespacedKey(identifier, RawStorageKey.SnjsVersion)) {
return '10.0.0'
}
@@ -49,7 +49,7 @@ describe('web application', () => {
componentManager.legacyGetDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
application.prepareForLaunch({ receiveChallenge: jest.fn() })
await application.prepareForLaunch({ receiveChallenge: jest.fn() })
})
describe('geDefaultEditorIdentifier', () => {

View File

@@ -90,8 +90,12 @@ export class WebApplication extends SNApplication implements WebApplicationInter
deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches,
allowMultipleSelection: deviceInterface.environment !== Environment.Mobile,
allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile,
u2fAuthenticatorRegistrationPromptFunction: startRegistration,
u2fAuthenticatorVerificationPromptFunction: startAuthentication,
u2fAuthenticatorRegistrationPromptFunction: startRegistration as unknown as (
registrationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>,
u2fAuthenticatorVerificationPromptFunction: startAuthentication as unknown as (
authenticationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>,
})
if (isDev) {

View File

@@ -106,7 +106,11 @@ const ChallengeModal: FunctionComponent<Props> = ({
*/
setTimeout(() => {
if (valuesToProcess.length > 0) {
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
if (challenge.customHandler) {
void challenge.customHandler(challenge, valuesToProcess)
} else {
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
}
} else {
setIsProcessing(false)
}

View File

@@ -34,7 +34,6 @@ import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import EditingDisabledBanner from './EditingDisabledBanner'
import { reloadFont } from './FontFunctions'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
import { NoteViewProps } from './NoteViewProps'
import {
@@ -45,6 +44,7 @@ import { SuperEditorContentId } from '../SuperEditor/Constants'
import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import CollaborationInfoHUD from './CollaborationInfoHUD'
import Button from '../Button/Button'
import ModalOverlay from '../Modal/ModalOverlay'

View File

@@ -10,6 +10,7 @@ import Listed from './Panes/Listed/Listed'
import HelpAndFeedback from './Panes/HelpFeedback'
import { PreferencesProps } from './PreferencesProps'
import WhatsNew from './Panes/WhatsNew/WhatsNew'
import HomeServer from './Panes/HomeServer/HomeServer'
import Vaults from './Panes/Vaults/Vaults'
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = ({
@@ -32,6 +33,8 @@ const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu
return <AccountPreferences application={application} viewControllerManager={viewControllerManager} />
case 'appearance':
return <Appearance application={application} />
case 'home-server':
return <HomeServer />
case 'security':
return (
<Security

View File

@@ -1,4 +1,5 @@
import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/Application/WebApplication'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import Authentication from './Authentication'
@@ -17,25 +18,29 @@ type Props = {
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} />
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
{application.hasAccount() && viewControllerManager.featuresController.entitledToFiles && (
<FilesSection application={application} />
)}
{application.hasAccount() && <Email application={application} />}
<SignOutWrapper application={application} viewControllerManager={viewControllerManager} />
<DeleteAccount application={application} viewControllerManager={viewControllerManager} />
</PreferencesPane>
)
const AccountPreferences = ({ application, viewControllerManager }: Props) => {
const isUsingThirdPartyServer = application.isThirdPartyHostUsed()
return (
<PreferencesPane>
{!application.hasAccount() ? (
<Authentication application={application} viewControllerManager={viewControllerManager} />
) : (
<>
<Credentials application={application} viewControllerManager={viewControllerManager} />
<Sync application={application} />
</>
)}
<Subscription application={application} viewControllerManager={viewControllerManager} />
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
{application.hasAccount() && viewControllerManager.featuresController.entitledToFiles && (
<FilesSection application={application} />
)}
{application.hasAccount() && !isUsingThirdPartyServer && <Email application={application} />}
<SignOutWrapper application={application} viewControllerManager={viewControllerManager} />
<DeleteAccount application={application} viewControllerManager={viewControllerManager} />
</PreferencesPane>
)
}
export default observer(AccountPreferences)

View File

@@ -21,15 +21,18 @@ const FilesSection: FunctionComponent<Props> = ({ application }) => {
const filesQuotaUsed = await application.settings.getSubscriptionSetting(
SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue(),
)
const filesQuotaTotal = await application.settings.getSubscriptionSetting(
SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(),
)
if (filesQuotaUsed) {
setFilesQuotaUsed(parseFloat(filesQuotaUsed))
}
if (filesQuotaTotal) {
setFilesQuotaTotal(parseFloat(filesQuotaTotal))
if (!application.isThirdPartyHostUsed()) {
const filesQuotaTotal = await application.settings.getSubscriptionSetting(
SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(),
)
if (filesQuotaTotal) {
setFilesQuotaTotal(parseFloat(filesQuotaTotal))
}
}
setIsLoading(false)
@@ -51,7 +54,7 @@ const FilesSection: FunctionComponent<Props> = ({ application }) => {
<>
<div className="mt-1 mb-1">
<span className="font-semibold">{formatSizeToReadableString(filesQuotaUsed)}</span> of{' '}
<span>{formatSizeToReadableString(filesQuotaTotal)}</span> used
<span>{application.isThirdPartyHostUsed() ? '∞' : formatSizeToReadableString(filesQuotaTotal)}</span> used
</div>
<progress
className="progress-bar w-full"

View File

@@ -15,13 +15,15 @@ type Props = {
}
const Backups: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
const isUsingThirdPartyServer = application.isThirdPartyHostUsed()
return (
<PreferencesPane>
<DataBackups application={application} viewControllerManager={viewControllerManager} />
<TextBackupsCrossPlatform application={application} />
<PlaintextBackupsCrossPlatform />
<FileBackupsCrossPlatform application={application} />
<EmailBackups application={application} />
{!isUsingThirdPartyServer && <EmailBackups application={application} />}
</PreferencesPane>
)
}

View File

@@ -12,9 +12,10 @@ import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
onSuccess?: () => void
}
const OfflineSubscription: FunctionComponent<Props> = ({ application }) => {
const OfflineSubscription: FunctionComponent<Props> = ({ application, onSuccess }) => {
const [activationCode, setActivationCode] = useState('')
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false)
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false)
@@ -33,14 +34,44 @@ const OfflineSubscription: FunctionComponent<Props> = ({ application }) => {
const handleSubscriptionCodeSubmit = async (event: React.FormEvent) => {
event.preventDefault()
const homeServer = application.homeServer
const homeServerEnabled = homeServer && homeServer.isHomeServerEnabled()
const homeServerIsRunning = homeServerEnabled && (await homeServer.isHomeServerRunning())
if (homeServerEnabled) {
if (!homeServerIsRunning) {
await application.alertService.alert('Please start your home server before activating offline features.')
return
}
const signedInUser = application.getUser()
if (!signedInUser) {
return
}
const serverActivationResult = await homeServer.activatePremiumFeatures(signedInUser.email)
if (serverActivationResult.isFailed()) {
await application.alertService.alert(serverActivationResult.getError())
return
}
}
const result = await application.features.setOfflineFeaturesCode(activationCode)
if (result instanceof ClientDisplayableError) {
await application.alertService.alert(result.text)
} else {
setIsSuccessfullyActivated(true)
setHasUserPreviouslyStoredCode(true)
setIsSuccessfullyRemoved(false)
return
}
setIsSuccessfullyActivated(true)
setHasUserPreviouslyStoredCode(true)
setIsSuccessfullyRemoved(false)
if (onSuccess) {
onSuccess()
}
}

View File

@@ -0,0 +1,62 @@
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import HomeServerSettings from './HomeServerSettings'
const HomeServer = () => {
return (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<HomeServerSettings />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<Title>Remote access</Title>
<Subtitle>Accessing your home server while on the go is easy and secure with Tailscale.</Subtitle>
<ol className="mt-3 ml-3 list-outside list-decimal">
<li>
Register on{' '}
<a className="text-info" href="https://tailscale.com/">
Tailscale.com
</a>{' '}
for free.
</li>
<li className="mt-2">
Download Tailscale on this computer and complete the Tailscale setup wizard until you are presented with the
IP address of your computer. It should start with something like 100.xxx...
</li>
<li className="mt-2">Download Tailscale on your mobile device and sign into your Tailscale account.</li>
<li className="mt-2">Activate the Tailscale VPN on your mobile device.</li>
<li className="mt-2">
Open Standard Notes on your mobile device and sign into your home server by specifying the sync server URL
during sign in. The URL will be the Tailscale-based IP address of this computer, followed by the port number
of your home server. For example, if your computer Tailscale IP address is 100.112.45.106 and your home
server is running on port 3127, your sync server URL will be http://100.112.45.106:3127.
</li>
</ol>
</PreferencesGroup>
<PreferencesGroup>
<Title>Backing up your data</Title>
<Subtitle>
For automatic backups, you can place your server's data inside of a synced cloud folder, like Dropbox,
Tresorit, or iCloud Drive.
</Subtitle>
<ol className="mt-3 ml-3 list-outside list-decimal">
<li>Change your server's data location by selecting "Change Location" in the Home Server section above.</li>
<li className="mt-2">Select a cloud drive to store your server's data in.</li>
<li className="mt-2">Restart your home server.</li>
</ol>
<Text className="mt-3">
Your Standard Notes data is always end-to-end encrypted on disk, so your cloud provider will not be able to
read your notes or files.
</Text>
</PreferencesGroup>
</PreferencesPane>
)
}
export default HomeServer

View File

@@ -0,0 +1,413 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import Button from '@/Components/Button/Button'
import { Pill, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import { useApplication } from '@/Components/ApplicationProvider'
import EncryptionStatusItem from '../Security/EncryptionStatusItem'
import Icon from '@/Components/Icon/Icon'
import OfflineSubscription from '../General/Advanced/OfflineSubscription'
import EnvironmentConfiguration from './Settings/EnvironmentConfiguration'
import DatabaseConfiguration from './Settings/DatabaseConfiguration'
import { HomeServerEnvironmentConfiguration, HomeServerServiceInterface, classNames, sleep } from '@standardnotes/snjs'
import StatusIndicator from './Status/StatusIndicator'
import { Status } from './Status/Status'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
import Switch from '@/Components/Switch/Switch'
import AccordionItem from '@/Components/Shared/AccordionItem'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
const HomeServerSettings = () => {
const SERVER_SYNTHEIC_CHANGE_DELAY = 1500
const LOGS_REFRESH_INTERVAL = 5000
const application = useApplication()
const homeServerService = application.homeServer as HomeServerServiceInterface
const featuresService = application.features
const sessionsService = application.sessions
const viewControllerManager = application.getViewControllerManager()
const logsTextarea = useRef<HTMLTextAreaElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [showLogs, setShowLogs] = useState(false)
const [logs, setLogs] = useState<string[]>([])
const [status, setStatus] = useState<Status>()
const [homeServerDataLocation, setHomeServerDataLocation] = useState('')
const [isAPremiumUser, setIsAPremiumUser] = useState(false)
const [isSignedIn, setIsSignedIn] = useState(false)
const [showOfflineSubscriptionActivation, setShowOfflineSubscriptionActivation] = useState(false)
const [logsIntervalRef, setLogsIntervalRef] = useState<NodeJS.Timer | null>(null)
const [homeServerConfiguration, setHomeServerConfiguration] = useState<HomeServerEnvironmentConfiguration | null>(
null,
)
const [homeServerEnabled, setHomeServerEnabled] = useState(false)
const refreshStatus = useCallback(async () => {
const result = await homeServerService.getHomeServerStatus()
setStatus({
state: result.status === 'on' ? 'online' : result.errorMessage ? 'error' : 'offline',
message: result.status === 'on' ? 'Online' : result.errorMessage ? 'Offline' : 'Starting...',
description:
result.status === 'on' ? (
<>
Accessible on local network at{' '}
<a href={result.url} className="font-bold text-info" target="_blank">
{result.url}
</a>
</>
) : (
result.errorMessage ?? 'Your home server is offline.'
),
})
}, [homeServerService, setStatus])
const initialyLoadHomeServerConfiguration = useCallback(async () => {
if (!homeServerConfiguration) {
const homeServerConfiguration = await homeServerService.getHomeServerConfiguration()
if (homeServerConfiguration) {
setHomeServerConfiguration(homeServerConfiguration)
}
}
}, [homeServerConfiguration, homeServerService])
const toggleHomeServer = useCallback(async () => {
if (status?.state === 'restarting') {
return
}
if (homeServerEnabled) {
setStatus({ state: 'restarting', message: 'Shutting down...' })
const result = await homeServerService.disableHomeServer()
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
if (result.isFailed() && (await homeServerService.isHomeServerRunning())) {
setStatus({ state: 'error', message: result.getError() })
return
}
setHomeServerEnabled(await homeServerService.isHomeServerEnabled())
await refreshStatus()
} else {
setStatus({ state: 'restarting', message: 'Starting...' })
await homeServerService.enableHomeServer()
setHomeServerEnabled(await homeServerService.isHomeServerEnabled())
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
await refreshStatus()
void initialyLoadHomeServerConfiguration()
}
}, [homeServerEnabled, homeServerService, status, refreshStatus, initialyLoadHomeServerConfiguration])
const clearLogs = useCallback(
(hideLogs = false) => {
if (logsIntervalRef !== null) {
clearInterval(logsIntervalRef)
}
if (hideLogs) {
setShowLogs(false)
}
setLogs([])
},
[setLogs, logsIntervalRef],
)
const setupLogsRefresh = useCallback(async () => {
clearLogs()
setLogs(await homeServerService.getHomeServerLogs())
const interval = setInterval(async () => {
setLogs(await homeServerService.getHomeServerLogs())
}, LOGS_REFRESH_INTERVAL)
setLogsIntervalRef(interval)
}, [homeServerService, clearLogs])
useEffect(() => {
async function updateHomeServerDataLocation() {
const location = await homeServerService.getHomeServerDataLocation()
if (location) {
setHomeServerDataLocation(location)
}
}
void updateHomeServerDataLocation()
async function updateHomeServerEnabled() {
setHomeServerEnabled(await homeServerService.isHomeServerEnabled())
}
void updateHomeServerEnabled()
setIsAPremiumUser(featuresService.hasOfflineRepo())
setIsSignedIn(sessionsService.isSignedIn())
void initialyLoadHomeServerConfiguration()
void refreshStatus()
}, [featuresService, sessionsService, homeServerService, refreshStatus, initialyLoadHomeServerConfiguration])
const handleHomeServerConfigurationChange = useCallback(
async (changedServerConfiguration: HomeServerEnvironmentConfiguration) => {
try {
setStatus({ state: 'restarting', message: 'Applying changes and restarting...' })
setHomeServerConfiguration(changedServerConfiguration)
await homeServerService.stopHomeServer()
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
await homeServerService.setHomeServerConfiguration(changedServerConfiguration)
clearLogs(true)
const result = await homeServerService.startHomeServer()
if (result !== undefined) {
setStatus({ state: 'error', message: result })
}
void refreshStatus()
} catch (error) {
setStatus({ state: 'error', message: (error as Error).message })
}
},
[homeServerService, setStatus, setHomeServerConfiguration, refreshStatus, clearLogs],
)
const changeHomeServerDataLocation = useCallback(
async (location?: string) => {
try {
await homeServerService.stopHomeServer()
if (location === undefined) {
const oldLocation = await homeServerService.getHomeServerDataLocation()
const newLocationOrError = await homeServerService.changeHomeServerDataLocation()
if (newLocationOrError.isFailed()) {
setStatus({
state: 'error',
message: `${newLocationOrError.getError()}. Restoring to initial location in a moment...`,
})
await sleep(2 * SERVER_SYNTHEIC_CHANGE_DELAY)
await changeHomeServerDataLocation(oldLocation)
return
}
location = newLocationOrError.getValue()
}
setStatus({ state: 'restarting', message: 'Applying changes and restarting...' })
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
setHomeServerDataLocation(location)
clearLogs(true)
const result = await homeServerService.startHomeServer()
if (result !== undefined) {
setStatus({ state: 'error', message: result })
}
void refreshStatus()
} catch (error) {
setStatus({ state: 'error', message: (error as Error).message })
}
},
[homeServerService, setStatus, setHomeServerDataLocation, refreshStatus, clearLogs],
)
const openHomeServerDataLocation = useCallback(async () => {
try {
await homeServerService.openHomeServerDataLocation()
} catch (error) {
setStatus({ state: 'error', message: (error as Error).message })
}
}, [homeServerService])
const handleShowLogs = () => {
const newValueForShowingLogs = !showLogs
if (newValueForShowingLogs) {
void setupLogsRefresh()
} else {
if (logsIntervalRef) {
clearInterval(logsIntervalRef)
setLogsIntervalRef(null)
}
}
setShowLogs(newValueForShowingLogs)
}
function isTextareaScrolledToBottom(textarea: HTMLTextAreaElement) {
const { scrollHeight, scrollTop, clientHeight } = textarea
const scrolledToBottom = scrollTop + clientHeight >= scrollHeight
return scrolledToBottom
}
useLayoutEffect(() => {
if (logsTextarea.current) {
setIsAtBottom(isTextareaScrolledToBottom(logsTextarea.current))
}
}, [logs])
useEffect(() => {
const handleScroll = () => {
if (logsTextarea.current) {
setIsAtBottom(isTextareaScrolledToBottom(logsTextarea.current))
}
}
const textArea = logsTextarea.current
if (textArea) {
textArea.addEventListener('scroll', handleScroll)
}
return () => {
if (textArea) {
textArea.removeEventListener('scroll', handleScroll)
}
if (logsIntervalRef !== null) {
clearInterval(logsIntervalRef)
}
}
}, [logsIntervalRef])
useEffect(() => {
if (logsTextarea.current && isAtBottom) {
logsTextarea.current.scrollTop = logsTextarea.current.scrollHeight
}
}, [logs, isAtBottom])
return (
<>
<div className="flex items-center justify-between">
<div className="flex items-start">
<Title>Home Server</Title>
<Pill style={'success'}>Labs</Pill>
</div>
</div>
<div className="flex items-center justify-between">
<div className="mr-10 flex flex-col">
<Subtitle>Sync your data on a private cloud running on your home computer.</Subtitle>
</div>
<Switch disabled={status?.state === 'restarting'} onChange={toggleHomeServer} checked={homeServerEnabled} />
</div>
{homeServerEnabled && (
<div>
<StatusIndicator className={'mr-3'} status={status} homeServerService={homeServerService} />
{status?.state !== 'restarting' && (
<>
<HorizontalSeparator classes="my-4" />
<>
<Text className="mb-3">Home server is enabled. All data is stored at:</Text>
<EncryptionStatusItem
status={homeServerDataLocation || 'Not Set'}
icon={<Icon type="attachment-file" className="min-h-5 min-w-5" />}
checkmark={false}
/>
<div className="mt-2.5 flex flex-row">
<Button label="Open Location" className={'mr-3 text-xs'} onClick={openHomeServerDataLocation} />
<Button
label="Change Location"
className={'mr-3 text-xs'}
onClick={() => changeHomeServerDataLocation()}
/>
</div>
</>
<HorizontalSeparator classes="my-4" />
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Logs'} onClick={handleShowLogs}>
<div className="flex flex-row items-center">
<div className="flex max-w-full flex-grow flex-col">
<textarea
ref={logsTextarea}
disabled={true}
className="h-[500px] overflow-y-auto whitespace-pre-wrap bg-contrast p-2"
value={logs.join('\n')}
/>
</div>
</div>
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
{homeServerConfiguration && (
<>
<HorizontalSeparator classes="my-4" />
<DatabaseConfiguration
homeServerConfiguration={homeServerConfiguration}
setHomeServerConfigurationChangedCallback={handleHomeServerConfigurationChange}
/>
<HorizontalSeparator classes="my-4" />
<EnvironmentConfiguration
homeServerConfiguration={homeServerConfiguration}
setHomeServerConfigurationChangedCallback={handleHomeServerConfigurationChange}
/>
</>
)}
{isSignedIn && !isAPremiumUser && (
<>
<HorizontalSeparator classes="my-4" />
<div className={'mt-2 grid grid-cols-1 rounded-md border border-border p-4'}>
<div className="flex items-center">
<Icon
className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)}
type={PremiumFeatureIconName}
/>
<h1 className="sk-h3 m-0 text-sm font-semibold">Activate Premium Features</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
Enter your purchased offline subscription code to activate all the features offered by the home
server.
</p>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={() => {
setShowOfflineSubscriptionActivation(!showOfflineSubscriptionActivation)
}}
>
{showOfflineSubscriptionActivation ? 'Close' : 'Activate Premium Features'}
</Button>
{showOfflineSubscriptionActivation && (
<OfflineSubscription
application={application}
viewControllerManager={viewControllerManager}
onSuccess={() => {
setIsAPremiumUser(true)
setShowOfflineSubscriptionActivation(false)
}}
/>
)}
</div>
</>
)}
</>
)}
</div>
)}
</>
)
}
export default HomeServerSettings

View File

@@ -0,0 +1,179 @@
import { useCallback, useEffect, useState } from 'react'
import { HomeServerEnvironmentConfiguration } from '@standardnotes/snjs'
import AccordionItem from '@/Components/Shared/AccordionItem'
import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment'
import Button from '@/Components/Button/Button'
import { Subtitle } from '../../../PreferencesComponents/Content'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import RadioButtonGroup from '@/Components/RadioButtonGroup/RadioButtonGroup'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
type Props = {
homeServerConfiguration: HomeServerEnvironmentConfiguration
setHomeServerConfigurationChangedCallback: (homeServerConfiguration: HomeServerEnvironmentConfiguration) => void
}
const DatabaseConfiguration = ({ setHomeServerConfigurationChangedCallback, homeServerConfiguration }: Props) => {
const [valuesChanged, setValuesChanged] = useState(false)
const [selectedDatabaseEngine, setSelectedDatabaseEngine] = useState<string>(homeServerConfiguration.databaseEngine)
const [isMySQLSelected, setIsMySQLSelected] = useState(homeServerConfiguration.databaseEngine === 'mysql')
const [mysqlDatabase, setMysqlDatabase] = useState(homeServerConfiguration.mysqlConfiguration?.database || '')
const [mysqlHost, setMysqlHost] = useState(homeServerConfiguration.mysqlConfiguration?.host || '')
const [mysqlPassword, setMysqlPassword] = useState(homeServerConfiguration.mysqlConfiguration?.password || '')
const [mysqlPort, setMysqlPort] = useState(homeServerConfiguration.mysqlConfiguration?.port || 3306)
const [mysqlUsername, setMysqlUsername] = useState(homeServerConfiguration.mysqlConfiguration?.username || '')
useEffect(() => {
const databaseEngineChanged = homeServerConfiguration.databaseEngine !== selectedDatabaseEngine
setIsMySQLSelected(selectedDatabaseEngine === 'mysql')
let mysqlConfigurationChanged = false
if (selectedDatabaseEngine === 'mysql') {
const allMysqlInputsFilled = !!mysqlDatabase && !!mysqlHost && !!mysqlPassword && !!mysqlPort && !!mysqlUsername
mysqlConfigurationChanged =
allMysqlInputsFilled &&
(homeServerConfiguration.mysqlConfiguration?.username !== mysqlUsername ||
homeServerConfiguration.mysqlConfiguration?.password !== mysqlPassword ||
homeServerConfiguration.mysqlConfiguration?.host !== mysqlHost ||
homeServerConfiguration.mysqlConfiguration?.port !== mysqlPort ||
homeServerConfiguration.mysqlConfiguration?.database !== mysqlDatabase)
setValuesChanged(mysqlConfigurationChanged || databaseEngineChanged)
return
}
setValuesChanged(databaseEngineChanged)
}, [
homeServerConfiguration,
selectedDatabaseEngine,
mysqlDatabase,
mysqlHost,
mysqlPassword,
mysqlPort,
mysqlUsername,
])
const handleConfigurationChange = useCallback(async () => {
homeServerConfiguration.databaseEngine = selectedDatabaseEngine as 'sqlite' | 'mysql'
if (selectedDatabaseEngine === 'mysql') {
homeServerConfiguration.mysqlConfiguration = {
username: mysqlUsername,
password: mysqlPassword,
host: mysqlHost,
port: mysqlPort,
database: mysqlDatabase,
}
}
setHomeServerConfigurationChangedCallback(homeServerConfiguration)
setValuesChanged(false)
}, [
homeServerConfiguration,
selectedDatabaseEngine,
setHomeServerConfigurationChangedCallback,
mysqlUsername,
mysqlPassword,
mysqlHost,
mysqlPort,
mysqlDatabase,
])
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Database'}>
<div className="flex flex-row items-center">
<div className="flex max-w-full flex-grow flex-col">
<RadioButtonGroup
items={[
{ label: 'SQLite', value: 'sqlite' },
{ label: 'MySQL', value: 'mysql' },
]}
value={selectedDatabaseEngine}
onChange={setSelectedDatabaseEngine}
/>
{isMySQLSelected && (
<>
<div className={'mt-2'}>
In order to connect to a MySQL database, ensure that your system has MySQL installed. For detailed
instructions, visit the{' '}
<a className="text-info" href="https://dev.mysql.com/doc/mysql-installation-excerpt/8.0/en/">
MySQL website.
</a>
</div>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<Subtitle className={'mt-2'}>Database Username</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'username'}
defaultValue={homeServerConfiguration?.mysqlConfiguration?.username}
onChange={setMysqlUsername}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Database Password</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'password'}
defaultValue={homeServerConfiguration?.mysqlConfiguration?.password}
onChange={setMysqlPassword}
type="password"
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Database Host</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'host'}
defaultValue={homeServerConfiguration?.mysqlConfiguration?.host}
onChange={setMysqlHost}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Database Port</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'port'}
defaultValue={
homeServerConfiguration?.mysqlConfiguration?.port
? homeServerConfiguration?.mysqlConfiguration?.port.toString()
: ''
}
onChange={(port: string) => setMysqlPort(Number(port))}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Database Name</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'name'}
defaultValue={homeServerConfiguration?.mysqlConfiguration?.database}
onChange={setMysqlDatabase}
/>
</div>
</PreferencesSegment>
</>
)}
</div>
</div>
{valuesChanged && (
<Button className="mt-3 min-w-20" primary label="Apply & Restart" onClick={handleConfigurationChange} />
)}
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default DatabaseConfiguration

View File

@@ -0,0 +1,164 @@
import { useCallback, useEffect, useState } from 'react'
import AccordionItem from '@/Components/Shared/AccordionItem'
import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment'
import { Subtitle } from '../../../PreferencesComponents/Content'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { HomeServerEnvironmentConfiguration } from '@standardnotes/snjs'
import Dropdown from '@/Components/Dropdown/Dropdown'
type Props = {
homeServerConfiguration: HomeServerEnvironmentConfiguration
setHomeServerConfigurationChangedCallback: (homeServerConfiguration: HomeServerEnvironmentConfiguration) => void
}
const EnvironmentConfiguration = ({ setHomeServerConfigurationChangedCallback, homeServerConfiguration }: Props) => {
const [authJWT, setAuthJWT] = useState(homeServerConfiguration.authJwtSecret)
const [jwt, setJWT] = useState(homeServerConfiguration.jwtSecret)
const [pseudoParamsKey, setPseudoParamsKey] = useState(homeServerConfiguration.pseudoKeyParamsKey)
const [valetTokenSecret, setValetTokenSecret] = useState(homeServerConfiguration.valetTokenSecret)
const [port, setPort] = useState(homeServerConfiguration.port)
const [valuesChanged, setValuesChanged] = useState(false)
const [selectedLogLevel, setSelectedLogLevel] = useState(homeServerConfiguration.logLevel as string)
useEffect(() => {
const anyOfTheValuesHaveChanged =
homeServerConfiguration.authJwtSecret !== authJWT ||
homeServerConfiguration.jwtSecret !== jwt ||
homeServerConfiguration.pseudoKeyParamsKey !== pseudoParamsKey ||
homeServerConfiguration.valetTokenSecret !== valetTokenSecret ||
homeServerConfiguration.port !== port ||
homeServerConfiguration.logLevel !== selectedLogLevel
setValuesChanged(anyOfTheValuesHaveChanged)
}, [
homeServerConfiguration,
selectedLogLevel,
authJWT,
jwt,
pseudoParamsKey,
valetTokenSecret,
port,
setValuesChanged,
])
const handleConfigurationChange = useCallback(async () => {
homeServerConfiguration.authJwtSecret = authJWT
homeServerConfiguration.jwtSecret = jwt
homeServerConfiguration.pseudoKeyParamsKey = pseudoParamsKey
homeServerConfiguration.valetTokenSecret = valetTokenSecret
homeServerConfiguration.port = port
homeServerConfiguration.logLevel = selectedLogLevel ?? homeServerConfiguration.logLevel
setHomeServerConfigurationChangedCallback(homeServerConfiguration)
setValuesChanged(false)
}, [
setHomeServerConfigurationChangedCallback,
homeServerConfiguration,
selectedLogLevel,
authJWT,
jwt,
pseudoParamsKey,
valetTokenSecret,
port,
])
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Advanced settings'}>
<div className="flex flex-row items-center">
<div className="flex max-w-full flex-grow flex-col">
<PreferencesSegment>
<Subtitle className={'mt-2'}>Auth JWT Secret</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'Auth JWT Secret'}
defaultValue={homeServerConfiguration?.authJwtSecret}
onChange={setAuthJWT}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>JWT Secret</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'JWT Secret'}
defaultValue={homeServerConfiguration?.jwtSecret}
onChange={setJWT}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Encryption Server Key</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'Encryption Server Key'}
defaultValue={homeServerConfiguration?.encryptionServerKey}
disabled={true}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Pseudo Params Key</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'Pseudo Params Key'}
defaultValue={homeServerConfiguration?.pseudoKeyParamsKey}
onChange={setPseudoParamsKey}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Valet Token Secret</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'Valet Token Secret'}
defaultValue={homeServerConfiguration?.valetTokenSecret}
onChange={setValetTokenSecret}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Port</Subtitle>
<div className="text-xs">Changing the port will require you to sign out of all existing sessions.</div>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'Port'}
defaultValue={homeServerConfiguration?.port.toString()}
onChange={(port: string) => setPort(Number(port))}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle className={'mt-2'}>Log Level</Subtitle>
<div className={'mt-2'}>
<Dropdown
label="Log level"
items={[
{ label: 'Error', value: 'error' },
{ label: 'Warning', value: 'warn' },
{ label: 'Info', value: 'info' },
{ label: 'Debug', value: 'debug' },
]}
value={selectedLogLevel}
onChange={setSelectedLogLevel}
/>
</div>
</PreferencesSegment>
</div>
</div>
{valuesChanged && (
<Button className="mt-3 min-w-20" primary label="Apply & Restart" onClick={handleConfigurationChange} />
)}
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default EnvironmentConfiguration

View File

@@ -0,0 +1,5 @@
export type Status = {
state: 'restarting' | 'online' | 'error' | 'offline'
message: string
description?: string | JSX.Element
}

View File

@@ -0,0 +1,113 @@
import { classNames } from '@standardnotes/utils'
import Icon from '@/Components/Icon/Icon'
import { Status } from './Status'
import { ElementIds } from '@/Constants/ElementIDs'
import { useApplication } from '@/Components/ApplicationProvider'
import { HomeServerServiceInterface } from '@standardnotes/snjs'
import { useEffect, useState } from 'react'
type Props = {
status: Status | undefined
homeServerService: HomeServerServiceInterface
className?: string
}
const StatusIndicator = ({ status, className, homeServerService }: Props) => {
const application = useApplication()
const [signInStatusMessage, setSignInStatusMessage] = useState<string>('')
const [signInStatusIcon, setSignInStatusIcon] = useState<string>('')
const [signInStatusClassName, setSignInStatusClassName] = useState<string>('')
let statusClassName: string
let icon: string
switch (status?.state) {
case 'online':
statusClassName = 'bg-success text-success-contrast'
icon = 'check'
break
case 'error':
statusClassName = 'bg-danger text-danger-contrast'
icon = 'warning'
break
default:
statusClassName = 'bg-contrast'
icon = 'sync'
break
}
useEffect(() => {
async function updateSignedInStatus() {
const signedInUser = application.getUser()
if (signedInUser) {
const isUsingHomeServer = await application.isUsingHomeServer()
if (isUsingHomeServer) {
setSignInStatusMessage(`You are currently signed into your home server under ${signedInUser.email}`)
setSignInStatusClassName('bg-success')
setSignInStatusIcon('check')
} else {
setSignInStatusMessage(
`You are not currently signed into your home server. To use your home server, sign out of ${
signedInUser.email
}, then sign in or register using ${await homeServerService.getHomeServerUrl()}.`,
)
setSignInStatusClassName('bg-warning')
setSignInStatusIcon('warning')
}
} else {
setSignInStatusMessage(
`You are not currently signed into your home server. To use your home server, sign in or register using ${await homeServerService.getHomeServerUrl()}`,
)
setSignInStatusClassName('bg-warning')
setSignInStatusIcon('warning')
}
}
void updateSignedInStatus()
}, [application, homeServerService, setSignInStatusMessage])
return (
<>
<div className="mt-2.5 flex flex-row items-center">
<div className="note-status-tooltip-container relative">
<div
className={classNames(
'peer flex h-5 w-5 items-center justify-center rounded-full',
statusClassName,
className,
)}
aria-describedby={ElementIds.NoteStatusTooltip}
>
<Icon className={status?.state === 'restarting' ? 'animate-spin' : ''} type={icon} size="small" />
</div>
</div>
<div>
<div className={'mr-3 font-bold'}>{status?.message}</div>
<div className={'mr-3'}>{status?.description}</div>
</div>
</div>
{status?.state !== 'restarting' && (
<div className="mt-2.5 flex flex-row items-center">
<div className="note-status-tooltip-container relative">
<div
className={classNames(
'peer flex h-5 w-5 items-center justify-center rounded-full',
signInStatusClassName,
className,
)}
aria-describedby={ElementIds.NoteStatusTooltip}
>
<Icon type={signInStatusIcon} size="small" />
</div>
</div>
<div>
<div className={'mr-3'}>{signInStatusMessage}</div>
</div>
</div>
)}
</>
)
}
export default StatusIndicator

View File

@@ -4,12 +4,14 @@ import { WebApplication } from '@/Application/WebApplication'
import { PackageProvider } from './Panes/General/Advanced/Packages/Provider/PackageProvider'
import { securityPrefsHasBubble } from './Panes/Security/securityPrefsHasBubble'
import { PreferenceId } from '@standardnotes/ui-services'
import { isDesktopApplication } from '@/Utils'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
interface PreferencesMenuItem {
readonly id: PreferenceId
readonly icon: IconType
readonly label: string
readonly order: number
readonly hasBubble?: boolean
}
@@ -21,28 +23,32 @@ interface SelectableMenuItem extends PreferencesMenuItem {
* Items are in order of appearance
*/
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'whats-new', label: "What's New", icon: 'asterisk' },
{ 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' },
{ id: 'whats-new', label: "What's New", icon: 'asterisk', order: 0 },
{ id: 'account', label: 'Account', icon: 'user', order: 1 },
{ id: 'general', label: 'General', icon: 'settings', order: 3 },
{ id: 'security', label: 'Security', icon: 'security', order: 4 },
{ id: 'backups', label: 'Backups', icon: 'restore', order: 5 },
{ id: 'appearance', label: 'Appearance', icon: 'themes', order: 6 },
{ id: 'listed', label: 'Listed', icon: 'listed', order: 7 },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard', order: 8 },
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility', order: 9 },
{ id: 'get-free-month', label: 'Get a free month', icon: 'star', order: 10 },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help', order: 11 },
]
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'whats-new', label: "What's New", icon: 'asterisk' },
{ 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' },
{ id: 'whats-new', label: "What's New", icon: 'asterisk', order: 0 },
{ id: 'account', label: 'Account', icon: 'user', order: 1 },
{ id: 'general', label: 'General', icon: 'settings', order: 3 },
{ id: 'security', label: 'Security', icon: 'security', order: 4 },
{ id: 'backups', label: 'Backups', icon: 'restore', order: 5 },
{ id: 'appearance', label: 'Appearance', icon: 'themes', order: 6 },
{ id: 'listed', label: 'Listed', icon: 'listed', order: 7 },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help', order: 11 },
]
const DESKTOP_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'home-server', label: 'Home Server', icon: 'server', order: 5 },
]
export class PreferencesMenu {
@@ -52,10 +58,18 @@ export class PreferencesMenu {
constructor(private application: WebApplication, private readonly _enableUnfinishedFeatures: boolean) {
if (featureTrunkVaultsEnabled()) {
PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' })
READY_PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' })
PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square', order: 5 })
READY_PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square', order: 5 })
}
let menuItems = this._enableUnfinishedFeatures ? PREFERENCES_MENU_ITEMS : READY_PREFERENCES_MENU_ITEMS
if (isDesktopApplication()) {
menuItems = [...menuItems, ...DESKTOP_PREFERENCES_MENU_ITEMS]
}
this._menu = menuItems.sort((a, b) => a.order - b.order)
this._menu = this._enableUnfinishedFeatures ? PREFERENCES_MENU_ITEMS : READY_PREFERENCES_MENU_ITEMS
this.loadLatestVersions()

View File

@@ -7,7 +7,7 @@ export const getPurchaseFlowUrl = async (application: WebApplication): Promise<s
const currentUrl = window.location.origin
const successUrl = isDesktopApplication() ? 'standardnotes://' : currentUrl
if (application.noAccount()) {
if (application.noAccount() || application.isThirdPartyHostUsed()) {
return `${window.purchaseUrl}/offline?&success_url=${successUrl}`
}

View File

@@ -7,9 +7,10 @@ type Props = {
title: string | JSX.Element
className?: string
children?: ReactNode
onClick?: (expanded: boolean) => void
}
const AccordionItem: FunctionComponent<Props> = ({ title, className = '', children }) => {
const AccordionItem: FunctionComponent<Props> = ({ title, className = '', children, onClick }) => {
const elementRef = useRef<HTMLDivElement>(null)
const [isExpanded, setIsExpanded] = useState(false)
@@ -19,6 +20,9 @@ const AccordionItem: FunctionComponent<Props> = ({ title, className = '', childr
className="relative flex cursor-pointer items-center justify-between hover:underline"
onClick={() => {
setIsExpanded(!isExpanded)
if (onClick) {
onClick(!isExpanded)
}
}}
>
<Title>{title}</Title>

View File

@@ -66,6 +66,7 @@ export class FeaturesController extends AbstractViewController {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
case ApplicationEvent.LocalDataLoaded:
case ApplicationEvent.UserRolesChanged:
runInAction(() => {
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()