internal: change password preprocessing step (#2347)

This commit is contained in:
Mo
2023-07-06 08:51:06 -05:00
committed by GitHub
parent 5c6ccaf4e1
commit c8e52b667c
39 changed files with 647 additions and 332 deletions

View File

@@ -2,6 +2,11 @@ export enum AsymmetricMessagePayloadType {
ContactShare = 'contact-share',
SharedVaultRootKeyChanged = 'shared-vault-root-key-changed',
SenderKeypairChanged = 'sender-keypair-changed',
SharedVaultInvite = 'shared-vault-invite',
SharedVaultMetadataChanged = 'shared-vault-metadata-changed',
/**
* Shared Vault Invites conform to the asymmetric message protocol, but are sent via the dedicated
* SharedVaultInvite model and not the AsymmetricMessage model on the server side.
*/
SharedVaultInvite = 'shared-vault-invite',
}

View File

@@ -1,3 +1,4 @@
import { AsymmetricMessageServiceInterface } from './../AsymmetricMessage/AsymmetricMessageServiceInterface'
import { SyncOptions } from './../Sync/SyncOptions'
import { ImportDataReturnType } from './../Mutator/ImportDataUseCase'
import { ChallengeServiceInterface } from './../Challenge/ChallengeServiceInterface'
@@ -102,6 +103,8 @@ export interface ApplicationInterface {
get vaults(): VaultServiceInterface
get challenges(): ChallengeServiceInterface
get alerts(): AlertService
get asymmetric(): AsymmetricMessageServiceInterface
readonly identifier: ApplicationIdentifier
readonly platform: Platform
deviceInterface: DeviceInterface

View File

@@ -1,6 +1,6 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import { ContactServiceInterface } from './../Contacts/ContactServiceInterface'
import { AsymmetricMessageServerHash, ClientDisplayableError } from '@standardnotes/responses'
import { AsymmetricMessageServerHash, ClientDisplayableError, isClientDisplayableError } from '@standardnotes/responses'
import { SyncEvent, SyncEventReceivedAsymmetricMessagesData } from '../Event/SyncEvent'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
@@ -27,8 +27,12 @@ import { SendOwnContactChangeMessage } from './UseCase/SendOwnContactChangeMessa
import { GetOutboundAsymmetricMessages } from './UseCase/GetOutboundAsymmetricMessages'
import { GetInboundAsymmetricMessages } from './UseCase/GetInboundAsymmetricMessages'
import { GetVaultUseCase } from '../Vaults/UseCase/GetVault'
import { AsymmetricMessageServiceInterface } from './AsymmetricMessageServiceInterface'
export class AsymmetricMessageService extends AbstractService implements InternalEventHandlerInterface {
export class AsymmetricMessageService
extends AbstractService
implements AsymmetricMessageServiceInterface, InternalEventHandlerInterface
{
private messageServer: AsymmetricMessageServer
constructor(
@@ -69,7 +73,16 @@ export class AsymmetricMessageService extends AbstractService implements Interna
return usecase.execute()
}
async sendOwnContactChangeEventToAllContacts(data: UserKeyPairChangedEventData): Promise<void> {
public async downloadAndProcessInboundMessages(): Promise<void> {
const messages = await this.getInboundMessages()
if (isClientDisplayableError(messages)) {
return
}
await this.handleRemoteReceivedAsymmetricMessages(messages)
}
private async sendOwnContactChangeEventToAllContacts(data: UserKeyPairChangedEventData): Promise<void> {
if (!data.oldKeyPair || !data.oldSigningKeyPair) {
return
}

View File

@@ -0,0 +1,7 @@
import { AsymmetricMessageServerHash, ClientDisplayableError } from '@standardnotes/responses'
export interface AsymmetricMessageServiceInterface {
getOutboundMessages(): Promise<AsymmetricMessageServerHash[] | ClientDisplayableError>
getInboundMessages(): Promise<AsymmetricMessageServerHash[] | ClientDisplayableError>
downloadAndProcessInboundMessages(): Promise<void>
}

View File

@@ -237,7 +237,7 @@ export class ContactService
}
findTrustedContactForInvite(invite: SharedVaultInviteServerHash): TrustedContactInterface | undefined {
return this.findTrustedContact(invite.user_uuid)
return this.findTrustedContact(invite.sender_uuid)
}
getCollaborationIDForTrustedContact(contact: TrustedContactInterface): string {

View File

@@ -21,7 +21,7 @@ import { PublicKeySet } from '@standardnotes/encryption'
export class SelfContactManager {
public selfContact?: TrustedContactInterface
private shouldReloadSelfContact = true
private isReloadingSelfContact = false
private eventDisposers: (() => void)[] = []
@@ -32,16 +32,14 @@ export class SelfContactManager {
private session: SessionsClientInterface,
private singletons: SingletonManagerInterface,
) {
this.eventDisposers.push(
items.addObserver(ContentType.TrustedContact, () => {
this.shouldReloadSelfContact = true
}),
)
this.eventDisposers.push(
sync.addEventObserver((event) => {
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded || event === SyncEvent.LocalDataIncrementalLoad) {
void this.reloadSelfContact()
if (event === SyncEvent.LocalDataIncrementalLoad) {
this.loadSelfContactFromDatabase()
}
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) {
void this.reloadSelfContactAndCreateIfNecessary()
}
}),
)
@@ -49,13 +47,21 @@ export class SelfContactManager {
public async handleApplicationStage(stage: ApplicationStage): Promise<void> {
if (stage === ApplicationStage.LoadedDatabase_12) {
this.selfContact = this.singletons.findSingleton<TrustedContactInterface>(
ContentType.UserPrefs,
TrustedContact.singletonPredicate,
)
this.loadSelfContactFromDatabase()
}
}
private loadSelfContactFromDatabase(): void {
if (this.selfContact) {
return
}
this.selfContact = this.singletons.findSingleton<TrustedContactInterface>(
ContentType.TrustedContact,
TrustedContact.singletonPredicate,
)
}
public async updateWithNewPublicKeySet(publicKeySet: PublicKeySet) {
if (!InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)) {
return
@@ -74,12 +80,16 @@ export class SelfContactManager {
})
}
private async reloadSelfContact() {
private async reloadSelfContactAndCreateIfNecessary() {
if (!InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)) {
return
}
if (!this.shouldReloadSelfContact || this.isReloadingSelfContact) {
if (this.selfContact) {
return
}
if (this.isReloadingSelfContact) {
return
}
@@ -105,17 +115,13 @@ export class SelfContactManager {
}),
}
try {
this.selfContact = await this.singletons.findOrCreateSingleton<TrustedContactContent, TrustedContact>(
TrustedContact.singletonPredicate,
ContentType.TrustedContact,
FillItemContent<TrustedContactContent>(content),
)
this.selfContact = await this.singletons.findOrCreateSingleton<TrustedContactContent, TrustedContact>(
TrustedContact.singletonPredicate,
ContentType.TrustedContact,
FillItemContent<TrustedContactContent>(content),
)
this.shouldReloadSelfContact = false
} finally {
this.isReloadingSelfContact = false
}
this.isReloadingSelfContact = false
}
deinit() {

View File

@@ -283,6 +283,7 @@ export class EncryptionService
const usecase = new CreateNewItemsKeyWithRollbackUseCase(
this.mutator,
this.items,
this.storage,
this.operators,
this.rootKeyManager,
)

View File

@@ -1,8 +1,10 @@
import { StorageServiceInterface } from './../../../Storage/StorageServiceInterface'
import { ItemsKeyMutator, OperatorManager, findDefaultItemsKey } from '@standardnotes/encryption'
import { MutatorClientInterface } from '../../../Mutator/MutatorClientInterface'
import { ItemManagerInterface } from '../../../Item/ItemManagerInterface'
import { RootKeyManager } from '../../RootKey/RootKeyManager'
import { CreateNewDefaultItemsKeyUseCase } from './CreateNewDefaultItemsKey'
import { RemoveItemsLocallyUseCase } from '../../../UseCase/RemoveItemsLocally'
export class CreateNewItemsKeyWithRollbackUseCase {
private createDefaultItemsKeyUseCase = new CreateNewDefaultItemsKeyUseCase(
@@ -12,9 +14,12 @@ export class CreateNewItemsKeyWithRollbackUseCase {
this.rootKeyManager,
)
private removeItemsLocallyUsecase = new RemoveItemsLocallyUseCase(this.items, this.storage)
constructor(
private mutator: MutatorClientInterface,
private items: ItemManagerInterface,
private storage: StorageServiceInterface,
private operatorManager: OperatorManager,
private rootKeyManager: RootKeyManager,
) {}
@@ -24,7 +29,7 @@ export class CreateNewItemsKeyWithRollbackUseCase {
const newDefaultItemsKey = await this.createDefaultItemsKeyUseCase.execute()
const rollback = async () => {
await this.mutator.setItemToBeDeleted(newDefaultItemsKey)
await this.removeItemsLocallyUsecase.execute([newDefaultItemsKey])
if (currentDefaultItemsKey) {
await this.mutator.changeItem<ItemsKeyMutator>(currentDefaultItemsKey, (mutator) => {

View File

@@ -111,7 +111,9 @@ export class SharedVaultService
)
this.eventDisposers.push(
items.addObserver<TrustedContactInterface>(ContentType.TrustedContact, ({ changed, inserted, source }) => {
items.addObserver<TrustedContactInterface>(ContentType.TrustedContact, async ({ changed, inserted, source }) => {
await this.reprocessCachedInvitesTrustStatusAfterTrustedContactsChange()
if (source === PayloadEmitSource.LocalChanged && inserted.length > 0) {
void this.handleCreationOfNewTrustedContacts(inserted)
}
@@ -250,8 +252,6 @@ export class SharedVaultService
}
private async handleTrustedContactsChange(contacts: TrustedContactInterface[]): Promise<void> {
await this.reprocessCachedInvitesTrustStatusAfterTrustedContactsChange()
for (const contact of contacts) {
await this.shareContactWithUserAdministeredSharedVaults(contact)
}
@@ -328,28 +328,9 @@ export class SharedVaultService
}
private async reprocessCachedInvitesTrustStatusAfterTrustedContactsChange(): Promise<void> {
const cachedInvites = this.getCachedPendingInviteRecords()
const cachedInvites = this.getCachedPendingInviteRecords().map((record) => record.invite)
for (const record of cachedInvites) {
if (record.trusted) {
continue
}
const trustedMessageUseCase = new GetAsymmetricMessageTrustedPayload<AsymmetricMessageSharedVaultInvite>(
this.encryption,
this.contacts,
)
const trustedMessage = trustedMessageUseCase.execute({
message: record.invite,
privateKey: this.encryption.getKeyPair().privateKey,
})
if (trustedMessage) {
record.message = trustedMessage
record.trusted = true
}
}
await this.processInboundInvites(cachedInvites)
}
private async processInboundInvites(invites: SharedVaultInviteServerHash[]): Promise<void> {

View File

@@ -3,10 +3,12 @@ import { StorageServiceInterface } from '../../Storage/StorageServiceInterface'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { AnyItemInterface, VaultListingInterface } from '@standardnotes/models'
import { Uuids } from '@standardnotes/utils'
import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface'
import { RemoveItemsLocallyUseCase } from '../../UseCase/RemoveItemsLocally'
export class DeleteExternalSharedVaultUseCase {
private removeItemsLocallyUsecase = new RemoveItemsLocallyUseCase(this.items, this.storage)
constructor(
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
@@ -28,15 +30,13 @@ export class DeleteExternalSharedVaultUseCase {
* The data will be removed locally without syncing the items
*/
private async deleteDataSharedByVaultUsers(vault: VaultListingInterface): Promise<void> {
const vaultItems = this.items
.allTrackedItems()
.filter((item) => item.key_system_identifier === vault.systemIdentifier)
this.items.removeItemsLocally(vaultItems as AnyItemInterface[])
const vaultItems = <AnyItemInterface[]>(
this.items.allTrackedItems().filter((item) => item.key_system_identifier === vault.systemIdentifier)
)
const itemsKeys = this.encryption.keys.getKeySystemItemsKeys(vault.systemIdentifier)
this.items.removeItemsLocally(itemsKeys)
await this.storage.deletePayloadsWithUuids([...Uuids(vaultItems), ...Uuids(itemsKeys)])
await this.removeItemsLocallyUsecase.execute([...vaultItems, ...itemsKeys])
}
private async deleteDataOwnedByThisUser(vault: VaultListingInterface): Promise<void> {

View File

@@ -0,0 +1,14 @@
import { StorageServiceInterface } from '../Storage/StorageServiceInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { AnyItemInterface } from '@standardnotes/models'
import { Uuids } from '@standardnotes/utils'
export class RemoveItemsLocallyUseCase {
constructor(private readonly items: ItemManagerInterface, private readonly storage: StorageServiceInterface) {}
async execute(items: AnyItemInterface[]): Promise<void> {
this.items.removeItemsLocally(items)
await this.storage.deletePayloadsWithUuids(Uuids(items))
}
}

View File

@@ -589,7 +589,6 @@ export class UserService
this.lockSyncing()
/** Now, change the credentials on the server. Roll back on failure */
const { response } = await this.sessionManager.changeCredentials({
currentServerPassword: currentRootKey.serverPassword as string,
newRootKey: newRootKey,

View File

@@ -9,6 +9,7 @@ export * from './Application/DeinitMode'
export * from './Application/DeinitSource'
export * from './AsymmetricMessage/AsymmetricMessageService'
export * from './AsymmetricMessage/AsymmetricMessageServiceInterface'
export * from './Auth/AuthClientInterface'
export * from './Auth/AuthManager'

View File

@@ -387,6 +387,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.challengeService
}
public get asymmetric(): ExternalServices.AsymmetricMessageServiceInterface {
return this.asymmetricMessageService
}
get homeServer(): ExternalServices.HomeServerServiceInterface | undefined {
return this.homeServerService
}

View File

@@ -9,6 +9,7 @@ import {
PayloadEmitSource,
EncryptedItemInterface,
getIncrementedDirtyIndex,
ContentTypeUsesRootKeyEncryption,
} from '@standardnotes/models'
import { SNSyncService } from '../Sync/SyncService'
import { DiskStorageService } from '../Storage/DiskStorageService'
@@ -187,6 +188,10 @@ export class SNKeyRecoveryService extends AbstractService<KeyRecoveryEvent, Decr
}
public canAttemptDecryptionOfItem(item: EncryptedItemInterface): ClientDisplayableError | true {
if (ContentTypeUsesRootKeyEncryption(item.content_type)) {
return true
}
const keyId = item.payload.items_key_id
if (!keyId) {

View File

@@ -437,6 +437,30 @@ describe('basic auth', function () {
expect(performSignIn.callCount).to.equal(1)
})
it('should rollback password change if fails to sync new items key', async function () {
/** Should delete the new items key locally without marking it as deleted so that it doesn't sync */
await this.context.register()
const originalImpl = this.application.encryptionService.getSureDefaultItemsKey
this.application.encryptionService.getSureDefaultItemsKey = () => {
return {
neverSynced: true,
}
}
const mutatorSpy = sinon.spy(this.application.mutator, 'setItemToBeDeleted')
const removeItemsSpy = sinon.spy(this.application.items, 'removeItemsLocally')
const deletePayloadsSpy = sinon.spy(this.application.storage, 'deletePayloadsWithUuids')
await this.context.changePassword('new-password')
this.application.encryptionService.getSureDefaultItemsKey = originalImpl
expect(mutatorSpy.callCount).to.equal(0)
expect(removeItemsSpy.callCount).to.equal(1)
expect(deletePayloadsSpy.callCount).to.equal(1)
})
describe('add passcode', function () {
it('should set passcode successfully', async function () {
const passcode = 'passcode'

View File

@@ -22,6 +22,8 @@ import {
Environment,
ApplicationOptionsDefaults,
BackupServiceInterface,
InternalFeatureService,
InternalFeatureServiceInterface,
} from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
@@ -263,6 +265,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return undefined
}
public getInternalFeatureService(): InternalFeatureServiceInterface {
return InternalFeatureService.get()
}
isNativeIOS() {
return this.isNativeMobileWeb() && this.platform === Platform.Ios
}

View File

@@ -0,0 +1,17 @@
import { CheckmarkCircle } from '../UIElements/CheckmarkCircle'
export const FinishStep = () => {
return (
<div className="flex flex-col gap-1">
<div className="flex flex-row items-start gap-3">
<div className="pt-1">
<CheckmarkCircle />
</div>
<div className="flex flex-col">
<div className="text-base font-bold">Your password has been successfully changed.</div>
<p>Ensure you are running the latest version of Standard Notes on all platforms for maximum compatibility.</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { useState } from 'react'
import DecoratedPasswordInput from '../Input/DecoratedPasswordInput'
export const PasswordStep = ({
onCurrentPasswordChange,
onNewPasswordChange,
onNewPasswordConfirmationChange,
}: {
onCurrentPasswordChange: (value: string) => void
onNewPasswordChange: (value: string) => void
onNewPasswordConfirmationChange: (value: string) => void
}) => {
const [currentPassword, setCurrentPassword] = useState<string>('')
const [newPassword, setNewPassword] = useState<string>('')
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState<string>('')
const handleCurrentPasswordChange = (value: string) => {
setCurrentPassword(value)
onCurrentPasswordChange(value)
}
const handleNewPasswordChange = (value: string) => {
setNewPassword(value)
onNewPasswordChange(value)
}
const handleNewPasswordConfirmationChange = (value: string) => {
setNewPasswordConfirmation(value)
onNewPasswordConfirmationChange(value)
}
return (
<div className="flex flex-col pb-1.5">
<form>
<label htmlFor="password-wiz-current-password" className="mb-1 block">
Current Password
</label>
<DecoratedPasswordInput
autofocus={true}
id="password-wiz-current-password"
value={currentPassword}
onChange={handleCurrentPasswordChange}
type="password"
/>
<div className="min-h-2" />
<label htmlFor="password-wiz-new-password" className="mb-1 block">
New Password
</label>
<DecoratedPasswordInput
id="password-wiz-new-password"
value={newPassword}
onChange={handleNewPasswordChange}
type="password"
/>
<div className="min-h-2" />
<label htmlFor="password-wiz-confirm-new-password" className="mb-1 block">
Confirm New Password
</label>
<DecoratedPasswordInput
id="password-wiz-confirm-new-password"
value={newPasswordConfirmation}
onChange={handleNewPasswordConfirmationChange}
type="password"
/>
</form>
</div>
)
}

View File

@@ -1,10 +1,12 @@
import { WebApplication } from '@/Application/WebApplication'
import { createRef } from 'react'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import DecoratedPasswordInput from '../Input/DecoratedPasswordInput'
import Modal from '../Modal/Modal'
import { isMobileScreen } from '@/Utils'
import Spinner from '../Spinner/Spinner'
import { PasswordStep } from './PasswordStep'
import { FinishStep } from './FinishStep'
import { PreprocessingStep } from './PreprocessingStep'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
interface Props {
application: WebApplication
@@ -19,7 +21,6 @@ type State = {
processing?: boolean
showSpinner?: boolean
step: Steps
title: string
}
const DEFAULT_CONTINUE_TITLE = 'Continue'
@@ -27,8 +28,9 @@ const GENERATING_CONTINUE_TITLE = 'Generating Keys...'
const FINISH_CONTINUE_TITLE = 'Finish'
enum Steps {
PasswordStep = 1,
FinishStep = 2,
PreprocessingStep = 'preprocessing-step',
PasswordStep = 'password-step',
FinishStep = 'finish-step',
}
type FormData = {
@@ -39,22 +41,32 @@ type FormData = {
}
class PasswordWizard extends AbstractComponent<Props, State> {
private currentPasswordInput = createRef<HTMLInputElement>()
constructor(props: Props) {
super(props, props.application)
this.registerWindowUnloadStopper()
this.state = {
const baseState = {
formData: {},
continueTitle: DEFAULT_CONTINUE_TITLE,
step: Steps.PasswordStep,
title: 'Change Password',
}
if (featureTrunkVaultsEnabled()) {
this.state = {
...baseState,
lockContinue: true,
step: Steps.PreprocessingStep,
}
} else {
this.state = {
...baseState,
lockContinue: false,
step: Steps.PasswordStep,
}
}
}
override componentDidMount(): void {
super.componentDidMount()
this.currentPasswordInput.current?.focus()
}
override componentWillUnmount(): void {
@@ -83,6 +95,15 @@ class PasswordWizard extends AbstractComponent<Props, State> {
if (this.state.step === Steps.FinishStep) {
this.dismiss()
return
}
if (this.state.step === Steps.PreprocessingStep) {
this.setState({
step: Steps.PasswordStep,
})
return
}
@@ -142,7 +163,6 @@ class PasswordWizard extends AbstractComponent<Props, State> {
return false
}
/** Validate current password */
const success = await this.application.validateAccountPassword(this.state.formData.currentPassword as string)
if (!success) {
this.application.alertService
@@ -192,7 +212,7 @@ class PasswordWizard extends AbstractComponent<Props, State> {
}
dismiss = () => {
if (this.state.lockContinue) {
if (this.state.processing) {
this.application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
this.props.dismissModal()
@@ -226,11 +246,32 @@ class PasswordWizard extends AbstractComponent<Props, State> {
}).catch(console.error)
}
setContinueEnabled = (enabled: boolean) => {
this.setState({
lockContinue: !enabled,
})
}
nextStepFromPreprocessing = () => {
if (this.state.lockContinue) {
this.setState(
{
lockContinue: false,
},
() => {
void this.nextStep()
},
)
} else {
void this.nextStep()
}
}
override render() {
return (
<div className="sn-component h-full w-full md:h-auto md:w-auto" id="password-wizard">
<Modal
title={this.state.title}
title={'Change Password'}
close={this.dismiss}
actions={[
{
@@ -253,59 +294,23 @@ class PasswordWizard extends AbstractComponent<Props, State> {
},
]}
>
<div className="px-4 py-4">
<div className="px-4.5 py-4">
{this.state.step === Steps.PreprocessingStep && (
<PreprocessingStep
onContinue={this.nextStepFromPreprocessing}
setContinueEnabled={this.setContinueEnabled}
/>
)}
{this.state.step === Steps.PasswordStep && (
<div className="flex flex-col pb-1.5">
<form>
<label htmlFor="password-wiz-current-password" className="mb-1 block">
Current Password
</label>
<DecoratedPasswordInput
ref={this.currentPasswordInput}
id="password-wiz-current-password"
value={this.state.formData.currentPassword}
onChange={this.handleCurrentPasswordInputChange}
type="password"
/>
<div className="min-h-2" />
<label htmlFor="password-wiz-new-password" className="mb-1 block">
New Password
</label>
<DecoratedPasswordInput
id="password-wiz-new-password"
value={this.state.formData.newPassword}
onChange={this.handleNewPasswordInputChange}
type="password"
/>
<div className="min-h-2" />
<label htmlFor="password-wiz-confirm-new-password" className="mb-1 block">
Confirm New Password
</label>
<DecoratedPasswordInput
id="password-wiz-confirm-new-password"
value={this.state.formData.newPasswordConfirmation}
onChange={this.handleNewPasswordConfirmationInputChange}
type="password"
/>
</form>
</div>
)}
{this.state.step === Steps.FinishStep && (
<div className="flex flex-col">
<div className="mb-1 font-bold text-info">Your password has been successfully changed.</div>
<p className="sk-p">
Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum
compatibility.
</p>
</div>
<PasswordStep
onCurrentPasswordChange={this.handleCurrentPasswordInputChange}
onNewPasswordChange={this.handleNewPasswordInputChange}
onNewPasswordConfirmationChange={this.handleNewPasswordConfirmationInputChange}
/>
)}
{this.state.step === Steps.FinishStep && <FinishStep />}
</div>
</Modal>
</div>

View File

@@ -0,0 +1,97 @@
import Spinner from '../Spinner/Spinner'
import { useApplication } from '../ApplicationProvider'
import { useCallback, useEffect, useState } from 'react'
export const PreprocessingStep = ({
onContinue,
setContinueEnabled,
}: {
onContinue: () => void
setContinueEnabled: (disabled: boolean) => void
}) => {
const application = useApplication()
const [isProcessingSync, setIsProcessingSync] = useState<boolean>(true)
const [isProcessingMessages, setIsProcessingMessages] = useState<boolean>(true)
const [isProcessingInvites, setIsProcessingInvites] = useState<boolean>(true)
const [needsUserConfirmation, setNeedsUserConfirmation] = useState<'yes' | 'no'>()
const continueIfPossible = useCallback(() => {
if (isProcessingMessages || isProcessingInvites || isProcessingSync) {
setContinueEnabled(false)
return
}
if (needsUserConfirmation === 'yes') {
setContinueEnabled(true)
return
}
onContinue()
}, [
isProcessingInvites,
isProcessingMessages,
isProcessingSync,
needsUserConfirmation,
onContinue,
setContinueEnabled,
])
useEffect(() => {
continueIfPossible()
}, [isProcessingInvites, isProcessingMessages, isProcessingSync, continueIfPossible])
useEffect(() => {
const processPendingSync = async () => {
await application.sync.sync()
setIsProcessingSync(false)
}
void processPendingSync()
}, [application.sync])
useEffect(() => {
const processPendingMessages = async () => {
await application.asymmetric.downloadAndProcessInboundMessages()
setIsProcessingMessages(false)
}
void processPendingMessages()
}, [application.asymmetric])
useEffect(() => {
const processPendingInvites = async () => {
await application.sharedVaults.downloadInboundInvites()
const hasPendingInvites = application.sharedVaults.getCachedPendingInviteRecords().length > 0
setNeedsUserConfirmation(hasPendingInvites ? 'yes' : 'no')
setIsProcessingInvites(false)
}
void processPendingInvites()
}, [application.sharedVaults])
const isProcessing = isProcessingSync || isProcessingMessages || isProcessingInvites
if (isProcessing) {
return (
<div className="flex flex-row items-center gap-3">
<Spinner className="h-3 w-3" />
<p className="">Checking for data conflicts...</p>
</div>
)
}
if (needsUserConfirmation === 'no') {
return null
}
return (
<div className="flex flex-col">
<p>
You have pending vault invites. Changing your password will delete these invites. It is recommended you accept
or decline these invites before changing your password. If you choose to continue, these invites will be
deleted.
</p>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { PreferencesMenuItem } from './PreferencesMenuItem'
export const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ 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 },
]
export const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ 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 },
]

View File

@@ -0,0 +1,10 @@
import { IconType } from '@standardnotes/snjs'
import { PreferenceId } from '@standardnotes/ui-services'
export interface PreferencesMenuItem {
readonly id: PreferenceId
readonly icon: IconType
readonly label: string
readonly order: number
readonly hasBubble?: boolean
}

View File

@@ -0,0 +1,100 @@
import { action, makeAutoObservable, observable } from 'mobx'
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 { featureTrunkHomeServerEnabled, featureTrunkVaultsEnabled } from '@/FeatureTrunk'
import { PreferencesMenuItem } from './PreferencesMenuItem'
import { SelectableMenuItem } from './SelectableMenuItem'
import { PREFERENCES_MENU_ITEMS, READY_PREFERENCES_MENU_ITEMS } from './MenuItems'
/**
* Unlike PreferencesController, the PreferencesSessionController is ephemeral and bound to a single opening of the
* Preferences menu. It is created and destroyed each time the menu is opened and closed.
*/
export class PreferencesSessionController {
private _selectedPane: PreferenceId = 'account'
private _menu: PreferencesMenuItem[]
private _extensionLatestVersions: PackageProvider = new PackageProvider(new Map())
constructor(private application: WebApplication, private readonly _enableUnfinishedFeatures: boolean) {
const menuItems = this._enableUnfinishedFeatures
? PREFERENCES_MENU_ITEMS.slice()
: READY_PREFERENCES_MENU_ITEMS.slice()
if (featureTrunkVaultsEnabled()) {
menuItems.push({ id: 'vaults', label: 'Vaults', icon: 'safe-square', order: 5 })
}
if (featureTrunkHomeServerEnabled() && isDesktopApplication()) {
menuItems.push({ id: 'home-server', label: 'Home Server', icon: 'server', order: 5 })
}
this._menu = menuItems.sort((a, b) => a.order - b.order)
this.loadLatestVersions()
makeAutoObservable<
PreferencesSessionController,
'_selectedPane' | '_twoFactorAuth' | '_extensionPanes' | '_extensionLatestVersions' | 'loadLatestVersions'
>(this, {
_twoFactorAuth: observable,
_selectedPane: observable,
_extensionPanes: observable.ref,
_extensionLatestVersions: observable.ref,
loadLatestVersions: action,
})
}
private loadLatestVersions(): void {
PackageProvider.load()
.then((versions) => {
if (versions) {
this._extensionLatestVersions = versions
}
})
.catch(console.error)
}
get extensionsLatestVersions(): PackageProvider {
return this._extensionLatestVersions
}
get menuItems(): SelectableMenuItem[] {
const menuItems = this._menu.map((preference) => {
const item: SelectableMenuItem = {
...preference,
selected: preference.id === this._selectedPane,
hasBubble: this.sectionHasBubble(preference.id),
}
return item
})
return menuItems
}
get selectedMenuItem(): PreferencesMenuItem | undefined {
return this._menu.find((item) => item.id === this._selectedPane)
}
get selectedPaneId(): PreferenceId {
if (this.selectedMenuItem != undefined) {
return this.selectedMenuItem.id
}
return 'account'
}
selectPane = (key: PreferenceId) => {
this._selectedPane = key
}
sectionHasBubble(id: PreferenceId): boolean {
if (id === 'security') {
return securityPrefsHasBubble(this.application)
}
return false
}
}

View File

@@ -0,0 +1,5 @@
import { PreferencesMenuItem } from './PreferencesMenuItem'
export interface SelectableMenuItem extends PreferencesMenuItem {
selected: boolean
}

View File

@@ -1,6 +1,6 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import { PreferencesSessionController } from './Controller/PreferencesSessionController'
import Backups from '@/Components/Preferences/Panes/Backups/Backups'
import Appearance from './Panes/Appearance'
import General from './Panes/General/General'
@@ -13,7 +13,7 @@ import WhatsNew from './Panes/WhatsNew/WhatsNew'
import HomeServer from './Panes/HomeServer/HomeServer'
import Vaults from './Panes/Vaults/Vaults'
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = ({
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesSessionController }> = ({
menu,
viewControllerManager,
application,

View File

@@ -1,10 +1,10 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { Fragment, FunctionComponent, useState } from 'react'
import { Fragment, FunctionComponent, useEffect, useState } from 'react'
import { Text, Title, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import {
ButtonType,
ClientDisplayableError,
ContentType,
DisplayStringForContentType,
EncryptedItemInterface,
} from '@standardnotes/snjs'
@@ -12,13 +12,18 @@ import Button from '@/Components/Button/Button'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import { ErrorCircle } from '@/Components/UIElements/ErrorCircle'
import { useApplication } from '@/Components/ApplicationProvider'
type Props = { viewControllerManager: ViewControllerManager }
const ErroredItems: FunctionComponent = () => {
const application = useApplication()
const [erroredItems, setErroredItems] = useState(application.items.invalidNonVaultedItems)
const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props) => {
const app = viewControllerManager.application
const [erroredItems, setErroredItems] = useState(app.items.invalidNonVaultedItems)
useEffect(() => {
return application.streamItems(ContentType.Any, () => {
setErroredItems(application.items.invalidNonVaultedItems)
})
}, [application])
const getContentTypeDisplay = (item: EncryptedItemInterface): string => {
const display = DisplayStringForContentType(item.content_type)
@@ -34,7 +39,7 @@ const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props
}
const deleteItems = async (items: EncryptedItemInterface[]): Promise<void> => {
const confirmed = await app.alertService.confirm(
const confirmed = await application.alertService.confirm(
`Are you sure you want to permanently delete ${items.length} item(s)?`,
undefined,
'Delete',
@@ -44,30 +49,35 @@ const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props
return
}
void app.mutator.deleteItems(items).then(() => {
void app.sync.sync()
void application.mutator.deleteItems(items).then(() => {
void application.sync.sync()
})
setErroredItems(app.items.invalidItems)
setErroredItems(application.items.invalidItems)
}
const attemptDecryption = (item: EncryptedItemInterface): void => {
const errorOrTrue = app.canAttemptDecryptionOfItem(item)
const errorOrTrue = application.canAttemptDecryptionOfItem(item)
if (errorOrTrue instanceof ClientDisplayableError) {
void app.alertService.showErrorAlert(errorOrTrue)
void application.alertService.showErrorAlert(errorOrTrue)
return
}
app.presentKeyRecoveryWizard()
application.presentKeyRecoveryWizard()
}
if (erroredItems.length === 0) {
return null
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>
Error decrypting items <span className="ml-1 text-warning"></span>
<Title className="flex flex-row items-center gap-2">
<ErrorCircle />
Error decrypting items
</Title>
<Text>{`${erroredItems.length} items are errored and could not be decrypted.`}</Text>
<div className="flex">
@@ -75,7 +85,7 @@ const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props
className="mt-3 mr-2 min-w-20"
label="Export all"
onClick={() => {
void app.getArchiveService().downloadEncryptedItems(erroredItems)
void application.getArchiveService().downloadEncryptedItems(erroredItems)
}}
/>
<Button
@@ -95,10 +105,8 @@ const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>{`${getContentTypeDisplay(item)} created on ${item.createdAtString}`}</Subtitle>
<Text>
<div>Item ID: {item.uuid}</div>
<div>Last Modified: {item.updatedAtString}</div>
</Text>
<Text>Item ID: {item.uuid}</Text>
<Text>Last Modified: {item.updatedAtString}</Text>
<div className="flex">
<Button
className="mt-3 mr-2 min-w-20"
@@ -111,7 +119,7 @@ const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props
className="mt-3 mr-2 min-w-20"
label="Export"
onClick={() => {
void app.getArchiveService().downloadEncryptedItem(item)
void application.getArchiveService().downloadEncryptedItem(item)
}}
/>
<Button

View File

@@ -30,9 +30,7 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
return (
<PreferencesPane>
<Encryption viewControllerManager={props.viewControllerManager} />
{props.application.items.invalidNonVaultedItems.length > 0 && (
<ErroredItems viewControllerManager={props.viewControllerManager} />
)}
{props.application.items.invalidNonVaultedItems.length > 0 && <ErroredItems />}
<Protections application={props.application} />
<TwoFactorAuthWrapper
mfaProvider={props.mfaProvider}

View File

@@ -25,7 +25,7 @@ const ContactItem = ({ contact }: Props) => {
return (
<>
<ModalOverlay isOpen={isContactModalOpen} close={closeContactModal}>
<EditContactModal editContactUuid={contact.uuid} onCloseDialog={closeContactModal} />
<EditContactModal editContactUuid={contact.contactUuid} onCloseDialog={closeContactModal} />
</ModalOverlay>
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">

View File

@@ -5,33 +5,36 @@ import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { PendingSharedVaultInviteRecord } from '@standardnotes/snjs'
import { useCallback, useState } from 'react'
import EditContactModal from '../Contacts/EditContactModal'
import { CheckmarkCircle } from '../../../../UIElements/CheckmarkCircle'
type Props = {
invite: PendingSharedVaultInviteRecord
inviteRecord: PendingSharedVaultInviteRecord
}
const InviteItem = ({ invite }: Props) => {
const InviteItem = ({ inviteRecord }: Props) => {
const application = useApplication()
const [isAddContactModalOpen, setIsAddContactModalOpen] = useState(false)
const isTrusted = invite.trusted
const inviteData = invite.message.data
const isTrusted = inviteRecord.trusted
const inviteData = inviteRecord.message.data
const addAsTrustedContact = useCallback(() => {
setIsAddContactModalOpen(true)
}, [])
const acceptInvite = useCallback(async () => {
await application.sharedVaults.acceptPendingSharedVaultInvite(invite)
}, [application.sharedVaults, invite])
await application.sharedVaults.acceptPendingSharedVaultInvite(inviteRecord)
}, [application.sharedVaults, inviteRecord])
const closeAddContactModal = () => setIsAddContactModalOpen(false)
const collaborationId = application.contacts.getCollaborationIDFromInvite(invite.invite)
const collaborationId = application.contacts.getCollaborationIDFromInvite(inviteRecord.invite)
const trustedContact = application.contacts.findTrustedContactForInvite(inviteRecord.invite)
return (
<>
<ModalOverlay isOpen={isAddContactModalOpen} close={closeAddContactModal}>
<EditContactModal fromInvite={invite} onCloseDialog={closeAddContactModal} />
<EditContactModal fromInvite={inviteRecord} onCloseDialog={closeAddContactModal} />
</ModalOverlay>
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
@@ -41,9 +44,16 @@ const InviteItem = ({ invite }: Props) => {
<span className="mr-auto overflow-hidden text-ellipsis text-sm">
Vault Description: {inviteData.metadata.description}
</span>
<span className="mr-auto overflow-hidden text-ellipsis text-sm">
Sender CollaborationID: {collaborationId}
</span>
{trustedContact ? (
<div className="flex flex-row gap-2">
<span className="overflow-hidden text-ellipsis text-sm">Trusted Sender: {trustedContact.name}</span>
<CheckmarkCircle />
</div>
) : (
<span className="mr-auto overflow-hidden text-ellipsis text-sm">
Sender CollaborationID: {collaborationId}
</span>
)}
<div className="mt-2.5 flex flex-row">
{isTrusted ? (

View File

@@ -40,31 +40,36 @@ const Vaults = () => {
setVaults(vaultService.getVaults())
}, [vaultService])
const fetchInvites = useCallback(async () => {
await sharedVaultService.downloadInboundInvites()
const invites = sharedVaultService.getCachedPendingInviteRecords()
setInvites(invites)
const updateInvites = useCallback(async () => {
setInvites(sharedVaultService.getCachedPendingInviteRecords())
}, [sharedVaultService])
const updateContacts = useCallback(async () => {
setContacts(contactService.getAllContacts())
}, [contactService])
const updateAllData = useCallback(async () => {
await Promise.all([updateVaults(), updateInvites(), updateContacts()])
}, [updateContacts, updateInvites, updateVaults])
useEffect(() => {
return application.sharedVaults.addEventObserver((event) => {
if (event === SharedVaultServiceEvent.SharedVaultStatusChanged) {
void fetchInvites()
void updateAllData()
}
})
})
}, [application.sharedVaults, updateAllData])
useEffect(() => {
return application.streamItems([ContentType.VaultListing, ContentType.TrustedContact], () => {
void updateVaults()
void fetchInvites()
void updateContacts()
void updateAllData()
})
}, [application, updateVaults, fetchInvites, updateContacts])
}, [application, updateAllData])
useEffect(() => {
void sharedVaultService.downloadInboundInvites()
void updateAllData()
}, [updateAllData, sharedVaultService])
const createNewVault = useCallback(async () => {
setIsVaultModalOpen(true)
@@ -74,12 +79,6 @@ const Vaults = () => {
setIsAddContactModalOpen(true)
}, [])
useEffect(() => {
void updateVaults()
void fetchInvites()
void updateContacts()
}, [updateContacts, updateVaults, fetchInvites])
return (
<>
<ModalOverlay isOpen={isAddContactModalOpen} close={closeAddContactModal}>
@@ -95,7 +94,7 @@ const Vaults = () => {
<Title>Incoming Invites</Title>
<div className="my-2 flex flex-col">
{invites.map((invite) => {
return <InviteItem invite={invite} key={invite.invite.uuid} />
return <InviteItem inviteRecord={invite} key={invite.invite.uuid} />
})}
</div>
</PreferencesSegment>

View File

@@ -28,9 +28,8 @@ export const VaultModalInvites = ({
<div className="mb-3 text-lg">Pending Invites</div>
{invites.map((invite) => {
const contact = application.contacts.findTrustedContactForInvite(invite)
return (
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
<div key={invite.uuid} className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
<Icon type={'user'} size="custom" className="mt-2.5 h-5.5 w-5.5 flex-shrink-0" />
<div className="flex flex-col gap-2 py-1.5">
<span className="mr-auto overflow-hidden text-ellipsis text-base font-bold">

View File

@@ -1,11 +1,11 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import { PreferencesSessionController } from './Controller/PreferencesSessionController'
import PreferencesMenuView from './PreferencesMenuView'
import PaneSelector from './PaneSelector'
import { PreferencesProps } from './PreferencesProps'
const PreferencesCanvas: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = (props) => (
const PreferencesCanvas: FunctionComponent<PreferencesProps & { menu: PreferencesSessionController }> = (props) => (
<div className="flex min-h-0 flex-grow flex-col md:flex-row md:justify-between">
<PreferencesMenuView menu={props.menu} />
<div className="min-h-0 flex-grow overflow-auto bg-contrast">

View File

@@ -1,6 +1,7 @@
import Icon from '@/Components/Icon/Icon'
import { FunctionComponent } from 'react'
import { IconType } from '@standardnotes/snjs'
import { ErrorCircle } from '@/Components/UIElements/ErrorCircle'
interface Props {
iconType: IconType
@@ -23,7 +24,11 @@ const PreferencesMenuItem: FunctionComponent<Props> = ({ iconType, label, select
<Icon className={`icon text-base ${selected ? 'text-info' : 'text-neutral'}`} type={iconType} />
<div className="min-w-1" />
{label}
{hasBubble && <span className="ml-1 text-warning"></span>}
{hasBubble && (
<span className="ml-2">
<ErrorCircle />
</span>
)}
</div>
)

View File

@@ -1,139 +0,0 @@
import { action, makeAutoObservable, observable } from 'mobx'
import { IconType } from '@standardnotes/snjs'
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 { featureTrunkHomeServerEnabled, featureTrunkVaultsEnabled } from '@/FeatureTrunk'
interface PreferencesMenuItem {
readonly id: PreferenceId
readonly icon: IconType
readonly label: string
readonly order: number
readonly hasBubble?: boolean
}
interface SelectableMenuItem extends PreferencesMenuItem {
selected: boolean
}
/**
* Items are in order of appearance
*/
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ 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', 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[] = []
export class PreferencesMenu {
private _selectedPane: PreferenceId = 'account'
private _menu: PreferencesMenuItem[]
private _extensionLatestVersions: PackageProvider = new PackageProvider(new Map())
constructor(private application: WebApplication, private readonly _enableUnfinishedFeatures: boolean) {
if (featureTrunkVaultsEnabled()) {
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 })
}
if (featureTrunkHomeServerEnabled()) {
DESKTOP_PREFERENCES_MENU_ITEMS.push({ id: 'home-server', label: 'Home Server', icon: 'server', 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.loadLatestVersions()
makeAutoObservable<
PreferencesMenu,
'_selectedPane' | '_twoFactorAuth' | '_extensionPanes' | '_extensionLatestVersions' | 'loadLatestVersions'
>(this, {
_twoFactorAuth: observable,
_selectedPane: observable,
_extensionPanes: observable.ref,
_extensionLatestVersions: observable.ref,
loadLatestVersions: action,
})
}
private loadLatestVersions(): void {
PackageProvider.load()
.then((versions) => {
if (versions) {
this._extensionLatestVersions = versions
}
})
.catch(console.error)
}
get extensionsLatestVersions(): PackageProvider {
return this._extensionLatestVersions
}
get menuItems(): SelectableMenuItem[] {
const menuItems = this._menu.map((preference) => {
const item: SelectableMenuItem = {
...preference,
selected: preference.id === this._selectedPane,
hasBubble: this.sectionHasBubble(preference.id),
}
return item
})
return menuItems
}
get selectedMenuItem(): PreferencesMenuItem | undefined {
return this._menu.find((item) => item.id === this._selectedPane)
}
get selectedPaneId(): PreferenceId {
if (this.selectedMenuItem != undefined) {
return this.selectedMenuItem.id
}
return 'account'
}
selectPane = (key: PreferenceId) => {
this._selectedPane = key
}
sectionHasBubble(id: PreferenceId): boolean {
if (id === 'security') {
return securityPrefsHasBubble(this.application)
}
return false
}
}

View File

@@ -3,11 +3,11 @@ import { FunctionComponent, useMemo } from 'react'
import Dropdown from '../Dropdown/Dropdown'
import { DropdownItem } from '../Dropdown/DropdownItem'
import PreferencesMenuItem from './PreferencesComponents/MenuItem'
import { PreferencesMenu } from './PreferencesMenu'
import { PreferencesSessionController } from './Controller/PreferencesSessionController'
import { PreferenceId } from '@standardnotes/ui-services'
type Props = {
menu: PreferencesMenu
menu: PreferencesSessionController
}
const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => {

View File

@@ -1,7 +1,7 @@
import RoundIconButton from '@/Components/Button/RoundIconButton'
import { FunctionComponent, useEffect, useMemo } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import { PreferencesSessionController } from './Controller/PreferencesSessionController'
import PreferencesCanvas from './PreferencesCanvas'
import { PreferencesProps } from './PreferencesProps'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
@@ -19,7 +19,7 @@ const PreferencesView: FunctionComponent<PreferencesProps> = ({
mfaProvider,
}) => {
const menu = useMemo(
() => new PreferencesMenu(application, viewControllerManager.enableUnfinishedFeatures),
() => new PreferencesSessionController(application, viewControllerManager.enableUnfinishedFeatures),
[viewControllerManager.enableUnfinishedFeatures, application],
)

View File

@@ -0,0 +1,13 @@
import Icon from '@/Components/Icon/Icon'
export const CheckmarkCircle = () => {
return (
<button
className={
'peer flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-success text-success-contrast'
}
>
<Icon type={'check'} size="small" />
</button>
)
}

View File

@@ -0,0 +1,13 @@
import Icon from '@/Components/Icon/Icon'
export const ErrorCircle = () => {
return (
<button
className={
'peer flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-danger text-danger-contrast'
}
>
<Icon type={'warning'} size="small" />
</button>
)
}