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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Status = {
|
||||
state: 'restarting' | 'online' | 'error' | 'offline'
|
||||
message: string
|
||||
description?: string | JSX.Element
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user