refactor: repo (#1070)

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

View File

@@ -0,0 +1,79 @@
import { observer } from 'mobx-react-lite'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebApplication } from '@/Application/Application'
import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { AccountMenuPane } from './AccountMenuPane'
import MenuPaneSelector from './MenuPaneSelector'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
onClickOutside: () => void
mainApplicationGroup: ApplicationGroup
}
const AccountMenu: FunctionComponent<Props> = ({
application,
viewControllerManager,
onClickOutside,
mainApplicationGroup,
}) => {
const { currentPane, shouldAnimateCloseMenu } = viewControllerManager.accountMenuController
const closeAccountMenu = useCallback(() => {
viewControllerManager.accountMenuController.closeAccountMenu()
}, [viewControllerManager])
const setCurrentPane = useCallback(
(pane: AccountMenuPane) => {
viewControllerManager.accountMenuController.setCurrentPane(pane)
},
[viewControllerManager],
)
const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => {
onClickOutside()
})
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
switch (event.key) {
case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu()
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register)
} else {
setCurrentPane(AccountMenuPane.GeneralMenu)
}
break
}
},
[closeAccountMenu, currentPane, setCurrentPane],
)
return (
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
onKeyDown={handleKeyDown}
>
<MenuPaneSelector
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}
/>
</div>
</div>
)
}
export default observer(AccountMenu)

View File

@@ -0,0 +1,6 @@
export enum AccountMenuPane {
GeneralMenu,
SignIn,
Register,
ConfirmPassword,
}

View File

@@ -0,0 +1,190 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useState } from 'react'
import Checkbox from '@/Components/Checkbox/Checkbox'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Icon from '@/Components/Icon/Icon'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
disabled?: boolean
onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void
onStrictSignInChange?: (isStrictSignIn: boolean) => void
}
const AdvancedOptions: FunctionComponent<Props> = ({
viewControllerManager,
application,
disabled = false,
onPrivateWorkspaceChange,
onStrictSignInChange,
children,
}) => {
const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController
const [showAdvanced, setShowAdvanced] = useState(false)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
const [privateWorkspaceName, setPrivateWorkspaceName] = useState('')
const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('')
const [isStrictSignin, setIsStrictSignin] = useState(false)
useEffect(() => {
const recomputePrivateWorkspaceIdentifier = async () => {
const identifier = await application.computePrivateWorkspaceIdentifier(
privateWorkspaceName,
privateWorkspaceUserphrase,
)
if (!identifier) {
if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) {
application.alertService.alert('Unable to compute private workspace name.').catch(console.error)
}
return
}
onPrivateWorkspaceChange?.(true, identifier)
}
if (privateWorkspaceName && privateWorkspaceUserphrase) {
recomputePrivateWorkspaceIdentifier().catch(console.error)
}
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange])
useEffect(() => {
onPrivateWorkspaceChange?.(isPrivateWorkspace)
}, [isPrivateWorkspace, onPrivateWorkspaceChange])
const handleIsPrivateWorkspaceChange = useCallback(() => {
setIsPrivateWorkspace(!isPrivateWorkspace)
}, [isPrivateWorkspace])
const handlePrivateWorkspaceNameChange = useCallback((name: string) => {
setPrivateWorkspaceName(name)
}, [])
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => {
setPrivateWorkspaceUserphrase(userphrase)
}, [])
const handleServerOptionChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (e.target instanceof HTMLInputElement) {
setEnableServerOption(e.target.checked)
}
},
[setEnableServerOption],
)
const handleSyncServerChange = useCallback(
(server: string) => {
setServer(server)
application.setCustomHost(server).catch(console.error)
},
[application, setServer],
)
const handleStrictSigninChange = useCallback(() => {
const newValue = !isStrictSignin
setIsStrictSignin(newValue)
onStrictSignInChange?.(newValue)
}, [isStrictSignin, onStrictSignInChange])
const toggleShowAdvanced = useCallback(() => {
setShowAdvanced(!showAdvanced)
}, [showAdvanced])
return (
<>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
onClick={toggleShowAdvanced}
>
<div className="flex items-center">
Advanced options
<Icon type="chevron-down" className="color-passive-1 ml-1" />
</div>
</button>
{showAdvanced ? (
<div className="px-3 my-2">
{children}
<div className="flex justify-between items-center mb-1">
<Checkbox
name="private-workspace"
label="Private workspace"
checked={isPrivateWorkspace}
disabled={disabled}
onChange={handleIsPrivateWorkspaceChange}
/>
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
<Icon type="info" className="color-neutral" />
</a>
</div>
{isPrivateWorkspace && (
<>
<DecoratedInput
className={'mb-2'}
left={[<Icon type="server" className="color-neutral" />]}
type="text"
placeholder="Userphrase"
value={privateWorkspaceUserphrase}
onChange={handlePrivateWorkspaceUserphraseChange}
disabled={disabled}
/>
<DecoratedInput
className={'mb-2'}
left={[<Icon type="folder" className="color-neutral" />]}
type="text"
placeholder="Name"
value={privateWorkspaceName}
onChange={handlePrivateWorkspaceNameChange}
disabled={disabled}
/>
</>
)}
{onStrictSignInChange && (
<div className="flex justify-between items-center mb-1">
<Checkbox
name="use-strict-signin"
label="Use strict sign-in"
checked={isStrictSignin}
disabled={disabled}
onChange={handleStrictSigninChange}
/>
<a
href="https://standardnotes.com/help/security"
target="_blank"
rel="noopener noreferrer"
title="Learn more"
>
<Icon type="info" className="color-neutral" />
</a>
</div>
)}
<Checkbox
name="custom-sync-server"
label="Custom sync server"
checked={enableServerOption}
onChange={handleServerOptionChange}
disabled={disabled}
/>
<DecoratedInput
type="text"
left={[<Icon type="server" className="color-neutral" />]}
placeholder="https://api.standardnotes.com"
value={server}
onChange={handleSyncServerChange}
disabled={!enableServerOption && !disabled}
/>
</div>
) : null}
</>
)
}
export default observer(AdvancedOptions)

View File

@@ -0,0 +1,160 @@
import { STRING_NON_MATCHING_PASSWORDS } from '@/Constants/Strings'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import Button from '@/Components/Button/Button'
import Checkbox from '@/Components/Checkbox/Checkbox'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import Icon from '@/Components/Icon/Icon'
import IconButton from '@/Components/Button/IconButton'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void
email: string
password: string
}
const ConfirmPassword: FunctionComponent<Props> = ({
application,
viewControllerManager,
setMenuPane,
email,
password,
}) => {
const { notesAndTagsCount } = viewControllerManager.accountMenuController
const [confirmPassword, setConfirmPassword] = useState('')
const [isRegistering, setIsRegistering] = useState(false)
const [isEphemeral, setIsEphemeral] = useState(false)
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [error, setError] = useState('')
const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
passwordInputRef.current?.focus()
}, [])
const handlePasswordChange = useCallback((text: string) => {
setConfirmPassword(text)
}, [])
const handleEphemeralChange = useCallback(() => {
setIsEphemeral(!isEphemeral)
}, [isEphemeral])
const handleShouldMergeChange = useCallback(() => {
setShouldMergeLocal(!shouldMergeLocal)
}, [shouldMergeLocal])
const handleConfirmFormSubmit = useCallback(
(e) => {
e.preventDefault()
if (!password) {
passwordInputRef.current?.focus()
return
}
if (password === confirmPassword) {
setIsRegistering(true)
application
.register(email, password, isEphemeral, shouldMergeLocal)
.then(() => {
viewControllerManager.accountMenuController.closeAccountMenu()
viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
})
.catch((err) => {
console.error(err)
setError(err.message)
})
.finally(() => {
setIsRegistering(false)
})
} else {
setError(STRING_NON_MATCHING_PASSWORDS)
setConfirmPassword('')
passwordInputRef.current?.focus()
}
},
[viewControllerManager, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal],
)
const handleKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (error.length) {
setError('')
}
if (e.key === 'Enter') {
handleConfirmFormSubmit(e)
}
},
[handleConfirmFormSubmit, error],
)
const handleGoBack = useCallback(() => {
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral p-0"
onClick={handleGoBack}
focusable={true}
disabled={isRegistering}
/>
<div className="sn-account-menu-headline">Confirm password</div>
</div>
<div className="px-3 mb-3 text-sm">
Because your notes are encrypted using your password,{' '}
<span className="color-danger">Standard Notes does not have a password reset option</span>. If you forget your
password, you will permanently lose access to your data.
</div>
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
<DecoratedPasswordInput
className="mb-2"
disabled={isRegistering}
left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
placeholder="Confirm password"
ref={passwordInputRef}
value={confirmPassword}
/>
{error ? <div className="color-danger my-2">{error}</div> : null}
<Button
className="btn-w-full mt-1 mb-3"
label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
variant="primary"
onClick={handleConfirmFormSubmit}
disabled={isRegistering}
/>
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
onChange={handleEphemeralChange}
disabled={isRegistering}
/>
{notesAndTagsCount > 0 ? (
<Checkbox
name="should-merge-local"
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={shouldMergeLocal}
onChange={handleShouldMergeChange}
disabled={isRegistering}
/>
) : null}
</form>
</>
)
}
export default observer(ConfirmPassword)

View File

@@ -0,0 +1,147 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import Icon from '@/Components/Icon/Icon'
import IconButton from '@/Components/Button/IconButton'
import AdvancedOptions from './AdvancedOptions'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void
email: string
setEmail: React.Dispatch<React.SetStateAction<string>>
password: string
setPassword: React.Dispatch<React.SetStateAction<string>>
}
const CreateAccount: FunctionComponent<Props> = ({
viewControllerManager,
application,
setMenuPane,
email,
setEmail,
password,
setPassword,
}) => {
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
useEffect(() => {
if (emailInputRef.current) {
emailInputRef.current?.focus()
}
}, [])
const handleEmailChange = useCallback(
(text: string) => {
setEmail(text)
},
[setEmail],
)
const handlePasswordChange = useCallback(
(text: string) => {
setPassword(text)
},
[setPassword],
)
const handleRegisterFormSubmit = useCallback(
(e) => {
e.preventDefault()
if (!email || email.length === 0) {
emailInputRef.current?.focus()
return
}
if (!password || password.length === 0) {
passwordInputRef.current?.focus()
return
}
setEmail(email)
setPassword(password)
setMenuPane(AccountMenuPane.ConfirmPassword)
},
[email, password, setPassword, setMenuPane, setEmail],
)
const handleKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === 'Enter') {
handleRegisterFormSubmit(e)
}
},
[handleRegisterFormSubmit],
)
const handleClose = useCallback(() => {
setMenuPane(AccountMenuPane.GeneralMenu)
setEmail('')
setPassword('')
}, [setEmail, setMenuPane, setPassword])
const onPrivateWorkspaceChange = useCallback(
(isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
setIsPrivateWorkspace(isPrivateWorkspace)
if (isPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral p-0"
onClick={handleClose}
focusable={true}
/>
<div className="sn-account-menu-headline">Create account</div>
</div>
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
<DecoratedInput
className="mb-2"
disabled={isPrivateWorkspace}
left={[<Icon type="email" className="color-neutral" />]}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
placeholder="Email"
ref={emailInputRef}
type="email"
value={email}
/>
<DecoratedPasswordInput
className="mb-2"
left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
placeholder="Password"
ref={passwordInputRef}
value={password}
/>
<Button className="btn-w-full mt-1" label="Next" variant="primary" onClick={handleRegisterFormSubmit} />
</form>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
application={application}
viewControllerManager={viewControllerManager}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
/>
</>
)
}
export default observer(CreateAccount)

View File

@@ -0,0 +1,188 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import Icon from '@/Components/Icon/Icon'
import { SyncQueueStrategy } from '@standardnotes/snjs'
import { STRING_GENERIC_SYNC_ERROR } from '@/Constants/Strings'
import { useCallback, useMemo, useState, FunctionComponent } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import WorkspaceSwitcherOption from './WorkspaceSwitcher/WorkspaceSwitcherOption'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { formatLastSyncDate } from '@/Utils/FormatLastSyncDate'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
mainApplicationGroup: ApplicationGroup
setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void
}
const iconClassName = 'color-neutral mr-2'
const GeneralAccountMenu: FunctionComponent<Props> = ({
application,
viewControllerManager,
setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
const doSynchronization = useCallback(async () => {
setIsSyncingInProgress(true)
application.sync
.sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true,
})
.then((res) => {
if (res && (res as any).error) {
throw new Error()
} else {
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
}
})
.catch(() => {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
})
.finally(() => {
setIsSyncingInProgress(false)
})
}, [application])
const user = useMemo(() => application.getUser(), [application])
const openPreferences = useCallback(() => {
viewControllerManager.accountMenuController.closeAccountMenu()
viewControllerManager.preferencesController.setCurrentPane('account')
viewControllerManager.preferencesController.openPreferences()
}, [viewControllerManager])
const openHelp = useCallback(() => {
viewControllerManager.accountMenuController.closeAccountMenu()
viewControllerManager.preferencesController.setCurrentPane('help-feedback')
viewControllerManager.preferencesController.openPreferences()
}, [viewControllerManager])
const signOut = useCallback(() => {
viewControllerManager.accountMenuController.setSigningOut(true)
}, [viewControllerManager])
const activateRegisterPane = useCallback(() => {
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
const activateSignInPane = useCallback(() => {
setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane])
const CREATE_ACCOUNT_INDEX = 1
const SWITCHER_INDEX = 0
return (
<>
<div className="flex items-center justify-between px-3 mt-1 mb-1">
<div className="sn-account-menu-headline">Account</div>
<div className="flex cursor-pointer" onClick={closeMenu}>
<Icon type="close" className="color-neutral" />
</div>
</div>
{user ? (
<>
<div className="px-3 mb-3 color-foreground text-sm">
<div>You're signed in as:</div>
<div className="my-0.5 font-bold wrap">{user.email}</div>
<span className="color-neutral">{application.getHost()}</span>
</div>
<div className="flex items-start justify-between px-3 mb-3">
{isSyncingInProgress ? (
<div className="flex items-center color-info font-semibold">
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
Syncing...
</div>
) : (
<div className="flex items-start">
<Icon type="check-circle" className="mr-2 success" />
<div>
<div className="font-semibold success">Last synced:</div>
<div className="color-text">{lastSyncDate}</div>
</div>
</div>
)}
<div className="flex cursor-pointer color-passive-1" onClick={doSynchronization}>
<Icon type="sync" />
</div>
</div>
</>
) : (
<>
<div className="px-3 mb-1">
<div className="mb-3 color-foreground">
Youre offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end
encryption.
</div>
<div className="flex items-center color-passive-1">
<Icon type="cloud-off" className="mr-2" />
<span className="font-semibold">Offline</span>
</div>
</div>
</>
)}
<Menu
isOpen={viewControllerManager.accountMenuController.show}
a11yLabel="General account menu"
closeMenu={closeMenu}
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
>
<MenuItemSeparator />
<WorkspaceSwitcherOption
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
/>
<MenuItemSeparator />
{user ? (
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}>
<Icon type="user" className={iconClassName} />
Account settings
</MenuItem>
) : (
<>
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
<Icon type="user" className={iconClassName} />
Create free account
</MenuItem>
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
<Icon type="signIn" className={iconClassName} />
Sign in
</MenuItem>
</>
)}
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
<div className="flex items-center">
<Icon type="help" className={iconClassName} />
Help &amp; feedback
</div>
<span className="color-neutral">v{application.version}</span>
</MenuItem>
{user ? (
<>
<MenuItemSeparator />
<MenuItem type={MenuItemType.IconButton} onClick={signOut}>
<Icon type="signOut" className={iconClassName} />
Sign out workspace
</MenuItem>
</>
) : null}
</Menu>
</>
)
}
export default observer(GeneralAccountMenu)

View File

@@ -0,0 +1,72 @@
import { WebApplication } from '@/Application/Application'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useState } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import ConfirmPassword from './ConfirmPassword'
import CreateAccount from './CreateAccount'
import GeneralAccountMenu from './GeneralAccountMenu'
import SignInPane from './SignIn'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
mainApplicationGroup: ApplicationGroup
menuPane: AccountMenuPane
setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void
}
const MenuPaneSelector: FunctionComponent<Props> = ({
application,
viewControllerManager,
menuPane,
setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
switch (menuPane) {
case AccountMenuPane.GeneralMenu:
return (
<GeneralAccountMenu
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
setMenuPane={setMenuPane}
closeMenu={closeMenu}
/>
)
case AccountMenuPane.SignIn:
return (
<SignInPane viewControllerManager={viewControllerManager} application={application} setMenuPane={setMenuPane} />
)
case AccountMenuPane.Register:
return (
<CreateAccount
viewControllerManager={viewControllerManager}
application={application}
setMenuPane={setMenuPane}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
/>
)
case AccountMenuPane.ConfirmPassword:
return (
<ConfirmPassword
viewControllerManager={viewControllerManager}
application={application}
setMenuPane={setMenuPane}
email={email}
password={password}
/>
)
}
}
export default observer(MenuPaneSelector)

View File

@@ -0,0 +1,214 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { isDev } from '@/Utils'
import { observer } from 'mobx-react-lite'
import React, { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import Button from '@/Components/Button/Button'
import Checkbox from '@/Components/Checkbox/Checkbox'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import Icon from '@/Components/Icon/Icon'
import IconButton from '@/Components/Button/IconButton'
import AdvancedOptions from './AdvancedOptions'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void
}
const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManager, setMenuPane }) => {
const { notesAndTagsCount } = viewControllerManager.accountMenuController
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isEphemeral, setIsEphemeral] = useState(false)
const [isStrictSignin, setIsStrictSignin] = useState(false)
const [isSigningIn, setIsSigningIn] = useState(false)
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (emailInputRef?.current) {
emailInputRef.current?.focus()
}
if (isDev && window.devAccountEmail) {
setEmail(window.devAccountEmail)
setPassword(window.devAccountPassword as string)
}
}, [])
const resetInvalid = useCallback(() => {
if (error.length) {
setError('')
}
}, [setError, error])
const handleEmailChange = useCallback((text: string) => {
setEmail(text)
}, [])
const handlePasswordChange = useCallback(
(text: string) => {
if (error.length) {
setError('')
}
setPassword(text)
},
[setPassword, error],
)
const handleEphemeralChange = useCallback(() => {
setIsEphemeral(!isEphemeral)
}, [isEphemeral])
const handleStrictSigninChange = useCallback(() => {
setIsStrictSignin(!isStrictSignin)
}, [isStrictSignin])
const handleShouldMergeChange = useCallback(() => {
setShouldMergeLocal(!shouldMergeLocal)
}, [shouldMergeLocal])
const signIn = useCallback(() => {
setIsSigningIn(true)
emailInputRef?.current?.blur()
passwordInputRef?.current?.blur()
application
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
.then((res) => {
if (res.error) {
throw new Error(res.error.message)
}
viewControllerManager.accountMenuController.closeAccountMenu()
})
.catch((err) => {
console.error(err)
setError(err.message ?? err.toString())
setPassword('')
passwordInputRef?.current?.blur()
})
.finally(() => {
setIsSigningIn(false)
})
}, [viewControllerManager, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
const onPrivateWorkspaceChange = useCallback(
(newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
setIsPrivateWorkspace(newIsPrivateWorkspace)
if (newIsPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
const handleSignInFormSubmit = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault()
if (!email || email.length === 0) {
emailInputRef?.current?.focus()
return
}
if (!password || password.length === 0) {
passwordInputRef?.current?.focus()
return
}
signIn()
},
[email, password, signIn],
)
const handleKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === 'Enter') {
handleSignInFormSubmit(e)
}
},
[handleSignInFormSubmit],
)
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral p-0"
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
focusable={true}
disabled={isSigningIn}
/>
<div className="sn-account-menu-headline">Sign in</div>
</div>
<div className="px-3 mb-1">
<DecoratedInput
className={`mb-2 ${error ? 'border-danger' : null}`}
left={[<Icon type="email" className="color-neutral" />]}
type="email"
placeholder="Email"
value={email}
onChange={handleEmailChange}
onFocus={resetInvalid}
onKeyDown={handleKeyDown}
disabled={isSigningIn || isPrivateWorkspace}
ref={emailInputRef}
/>
<DecoratedPasswordInput
className={`mb-2 ${error ? 'border-danger' : null}`}
disabled={isSigningIn}
left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange}
onFocus={resetInvalid}
onKeyDown={handleKeyDown}
placeholder="Password"
ref={passwordInputRef}
value={password}
/>
{error ? <div className="color-danger my-2">{error}</div> : null}
<Button
className="btn-w-full mt-1 mb-3"
label={isSigningIn ? 'Signing in...' : 'Sign in'}
variant="primary"
onClick={handleSignInFormSubmit}
disabled={isSigningIn}
/>
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
disabled={isSigningIn}
onChange={handleEphemeralChange}
/>
{notesAndTagsCount > 0 ? (
<Checkbox
name="should-merge-local"
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={shouldMergeLocal}
disabled={isSigningIn}
onChange={handleShouldMergeChange}
/>
) : null}
</div>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
viewControllerManager={viewControllerManager}
application={application}
disabled={isSigningIn}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
onStrictSignInChange={handleStrictSigninChange}
/>
</>
)
}
export default observer(SignInPane)

View File

@@ -0,0 +1,45 @@
import { observer } from 'mobx-react-lite'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebApplication } from '@/Application/Application'
import { User as UserType } from '@standardnotes/snjs'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
}
const User = ({ viewControllerManager, application }: Props) => {
const { server } = viewControllerManager.accountMenuController
const user = application.getUser() as UserType
return (
<div className="sk-panel-section">
{viewControllerManager.syncStatusController.errorMessage && (
<div className="sk-notification danger">
<div className="sk-notification-title">Sync Unreachable</div>
<div className="sk-notification-text">
Hmm...we can't seem to sync your account. The reason:{' '}
{viewControllerManager.syncStatusController.errorMessage}
</div>
<a
className="sk-a info-contrast sk-bold sk-panel-row"
href="https://standardnotes.com/help"
rel="noopener"
target="_blank"
>
Need help?
</a>
</div>
)}
<div className="sk-panel-row">
<div className="sk-panel-column">
<div className="sk-h1 sk-bold wrap">{user.email}</div>
<div className="sk-subtitle neutral">{server}</div>
</div>
</div>
<div className="sk-panel-row" />
</div>
)
}
export default observer(User)

View File

@@ -0,0 +1,107 @@
import Icon from '@/Components/Icon/Icon'
import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { KeyboardKey } from '@/Services/IOService'
import { ApplicationDescriptor } from '@standardnotes/snjs'
import {
ChangeEventHandler,
FocusEventHandler,
FunctionComponent,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type Props = {
descriptor: ApplicationDescriptor
onClick: () => void
onDelete: () => void
renameDescriptor: (label: string) => void
hideOptions: boolean
}
const WorkspaceMenuItem: FunctionComponent<Props> = ({
descriptor,
onClick,
onDelete,
renameDescriptor,
hideOptions,
}) => {
const [isRenaming, setIsRenaming] = useState(false)
const [inputValue, setInputValue] = useState(descriptor.label)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isRenaming) {
inputRef.current?.focus()
}
}, [isRenaming])
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
setInputValue(event.target.value)
}, [])
const handleInputKeyDown: KeyboardEventHandler = useCallback((event) => {
if (event.key === KeyboardKey.Enter) {
inputRef.current?.blur()
}
}, [])
const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(() => {
renameDescriptor(inputValue)
setIsRenaming(false)
setInputValue('')
}, [inputValue, renameDescriptor])
return (
<MenuItem
type={MenuItemType.RadioButton}
className="sn-dropdown-item py-2 focus:bg-info-backdrop focus:shadow-none"
onClick={onClick}
checked={descriptor.primary}
>
<div className="flex items-center justify-between w-full ml-2">
{isRenaming ? (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
/>
) : (
<div>{descriptor.label}</div>
)}
{descriptor.primary && !hideOptions && (
<div>
<a
role="button"
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setIsRenaming((isRenaming) => !isRenaming)
}}
>
<Icon type="pencil" className="sn-icon--mid color-neutral" />
</a>
<a
role="button"
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Icon type="trash" className="sn-icon--mid color-danger" />
</a>
</div>
)}
</div>
</MenuItem>
)
}
export default WorkspaceMenuItem

View File

@@ -0,0 +1,95 @@
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import WorkspaceMenuItem from './WorkspaceMenuItem'
type Props = {
mainApplicationGroup: ApplicationGroup
viewControllerManager: ViewControllerManager
isOpen: boolean
hideWorkspaceOptions?: boolean
}
const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
mainApplicationGroup,
viewControllerManager,
isOpen,
hideWorkspaceOptions = false,
}: Props) => {
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
useEffect(() => {
const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors)
const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => {
if (event === ApplicationGroupEvent.DescriptorsDataChanged) {
const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors)
}
})
return () => {
removeAppGroupObserver()
}
}, [mainApplicationGroup])
const signoutAll = useCallback(async () => {
const confirmed = await viewControllerManager.application.alertService.confirm(
'Are you sure you want to sign out of all workspaces on this device?',
undefined,
'Sign out all',
ButtonType.Danger,
)
if (!confirmed) {
return
}
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
}, [mainApplicationGroup, viewControllerManager])
const destroyWorkspace = useCallback(() => {
viewControllerManager.accountMenuController.setSigningOut(true)
}, [viewControllerManager])
return (
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
key={descriptor.identifier}
descriptor={descriptor}
hideOptions={hideWorkspaceOptions}
onDelete={destroyWorkspace}
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
{!hideWorkspaceOptions && (
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
<Icon type="signOut" className="color-neutral mr-2" />
Sign out all workspaces
</MenuItem>
)}
</Menu>
)
}
export default observer(WorkspaceSwitcherMenu)

View File

@@ -0,0 +1,72 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu'
type Props = {
mainApplicationGroup: ApplicationGroup
viewControllerManager: ViewControllerManager
}
const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
const toggleMenu = useCallback(() => {
if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsOpen(!isOpen)
}, [isOpen, setIsOpen])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
})
}
}, [isOpen])
return (
<>
<button
ref={buttonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
role="menuitem"
onClick={toggleMenu}
>
<div className="flex items-center">
<Icon type="user-switch" className="color-neutral mr-2" />
Switch workspace
</div>
<Icon type="chevron-right" className="color-neutral" />
</button>
{isOpen && (
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
isOpen={isOpen}
/>
</div>
)}
</>
)
}
export default observer(WorkspaceSwitcherOption)