From c8e52b667cff9a33666d4284825975102d882003 Mon Sep 17 00:00:00 2001 From: Mo Date: Thu, 6 Jul 2023 08:51:06 -0500 Subject: [PATCH] internal: change password preprocessing step (#2347) --- .../AsymmetricMessagePayloadType.ts | 7 +- .../Application/ApplicationInterface.ts | 3 + .../AsymmetricMessageService.ts | 19 ++- .../AsymmetricMessageServiceInterface.ts | 7 + .../src/Domain/Contacts/ContactService.ts | 2 +- .../Contacts/Managers/SelfContactManager.ts | 56 +++---- .../Domain/Encryption/EncryptionService.ts | 1 + .../ItemsKey/CreateNewItemsKeyWithRollback.ts | 7 +- .../Domain/SharedVaults/SharedVaultService.ts | 29 +--- .../UseCase/DeleteExternalSharedVault.ts | 14 +- .../src/Domain/UseCase/RemoveItemsLocally.ts | 14 ++ .../services/src/Domain/User/UserService.ts | 1 - packages/services/src/Domain/index.ts | 1 + packages/snjs/lib/Application/Application.ts | 4 + .../KeyRecovery/KeyRecoveryService.ts | 5 + packages/snjs/mocha/auth.test.js | 24 +++ .../javascripts/Application/WebApplication.ts | 6 + .../Components/PasswordWizard/FinishStep.tsx | 17 +++ .../PasswordWizard/PasswordStep.tsx | 75 ++++++++++ .../PasswordWizard/PasswordWizard.tsx | 135 +++++++++-------- .../PasswordWizard/PreprocessingStep.tsx | 97 ++++++++++++ .../Preferences/Controller/MenuItems.ts | 26 ++++ .../Controller/PreferencesMenuItem.ts | 10 ++ .../PreferencesSessionController.ts | 100 +++++++++++++ .../Controller/SelectableMenuItem.ts | 5 + .../Components/Preferences/PaneSelector.tsx | 4 +- .../Panes/Security/ErroredItems.tsx | 52 ++++--- .../Preferences/Panes/Security/Security.tsx | 4 +- .../Panes/Vaults/Contacts/ContactItem.tsx | 2 +- .../Panes/Vaults/Invites/InviteItem.tsx | 32 ++-- .../Preferences/Panes/Vaults/Vaults.tsx | 33 ++--- .../Vaults/VaultModal/VaultModalInvites.tsx | 3 +- .../Preferences/PreferencesCanvas.tsx | 4 +- .../PreferencesComponents/MenuItem.tsx | 7 +- .../Components/Preferences/PreferencesMenu.ts | 139 ------------------ .../Preferences/PreferencesMenuView.tsx | 4 +- .../Preferences/PreferencesView.tsx | 4 +- .../Components/UIElements/CheckmarkCircle.tsx | 13 ++ .../Components/UIElements/ErrorCircle.tsx | 13 ++ 39 files changed, 647 insertions(+), 332 deletions(-) create mode 100644 packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageServiceInterface.ts create mode 100644 packages/services/src/Domain/UseCase/RemoveItemsLocally.ts create mode 100644 packages/web/src/javascripts/Components/PasswordWizard/FinishStep.tsx create mode 100644 packages/web/src/javascripts/Components/PasswordWizard/PasswordStep.tsx create mode 100644 packages/web/src/javascripts/Components/PasswordWizard/PreprocessingStep.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts create mode 100644 packages/web/src/javascripts/Components/Preferences/Controller/PreferencesMenuItem.ts create mode 100644 packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts create mode 100644 packages/web/src/javascripts/Components/Preferences/Controller/SelectableMenuItem.ts delete mode 100644 packages/web/src/javascripts/Components/Preferences/PreferencesMenu.ts create mode 100644 packages/web/src/javascripts/Components/UIElements/CheckmarkCircle.tsx create mode 100644 packages/web/src/javascripts/Components/UIElements/ErrorCircle.tsx diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts index c4dab81c1..5b4f200c5 100644 --- a/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts @@ -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', } diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index 9cc18aeef..35cad8ce1 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -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 diff --git a/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts index 40d1f2fc7..f7609b374 100644 --- a/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts +++ b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts @@ -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 { + public async downloadAndProcessInboundMessages(): Promise { + const messages = await this.getInboundMessages() + if (isClientDisplayableError(messages)) { + return + } + + await this.handleRemoteReceivedAsymmetricMessages(messages) + } + + private async sendOwnContactChangeEventToAllContacts(data: UserKeyPairChangedEventData): Promise { if (!data.oldKeyPair || !data.oldSigningKeyPair) { return } diff --git a/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageServiceInterface.ts b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageServiceInterface.ts new file mode 100644 index 000000000..aef707438 --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageServiceInterface.ts @@ -0,0 +1,7 @@ +import { AsymmetricMessageServerHash, ClientDisplayableError } from '@standardnotes/responses' + +export interface AsymmetricMessageServiceInterface { + getOutboundMessages(): Promise + getInboundMessages(): Promise + downloadAndProcessInboundMessages(): Promise +} diff --git a/packages/services/src/Domain/Contacts/ContactService.ts b/packages/services/src/Domain/Contacts/ContactService.ts index 0d139583a..2a0aea17d 100644 --- a/packages/services/src/Domain/Contacts/ContactService.ts +++ b/packages/services/src/Domain/Contacts/ContactService.ts @@ -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 { diff --git a/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts b/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts index ace8c11fe..4b3dfa0e2 100644 --- a/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts +++ b/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts @@ -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 { if (stage === ApplicationStage.LoadedDatabase_12) { - this.selfContact = this.singletons.findSingleton( - ContentType.UserPrefs, - TrustedContact.singletonPredicate, - ) + this.loadSelfContactFromDatabase() } } + private loadSelfContactFromDatabase(): void { + if (this.selfContact) { + return + } + + this.selfContact = this.singletons.findSingleton( + 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( - TrustedContact.singletonPredicate, - ContentType.TrustedContact, - FillItemContent(content), - ) + this.selfContact = await this.singletons.findOrCreateSingleton( + TrustedContact.singletonPredicate, + ContentType.TrustedContact, + FillItemContent(content), + ) - this.shouldReloadSelfContact = false - } finally { - this.isReloadingSelfContact = false - } + this.isReloadingSelfContact = false } deinit() { diff --git a/packages/services/src/Domain/Encryption/EncryptionService.ts b/packages/services/src/Domain/Encryption/EncryptionService.ts index 358de08d4..c91c248da 100644 --- a/packages/services/src/Domain/Encryption/EncryptionService.ts +++ b/packages/services/src/Domain/Encryption/EncryptionService.ts @@ -283,6 +283,7 @@ export class EncryptionService const usecase = new CreateNewItemsKeyWithRollbackUseCase( this.mutator, this.items, + this.storage, this.operators, this.rootKeyManager, ) diff --git a/packages/services/src/Domain/Encryption/UseCase/ItemsKey/CreateNewItemsKeyWithRollback.ts b/packages/services/src/Domain/Encryption/UseCase/ItemsKey/CreateNewItemsKeyWithRollback.ts index b8141cfb3..f9ef0710a 100644 --- a/packages/services/src/Domain/Encryption/UseCase/ItemsKey/CreateNewItemsKeyWithRollback.ts +++ b/packages/services/src/Domain/Encryption/UseCase/ItemsKey/CreateNewItemsKeyWithRollback.ts @@ -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(currentDefaultItemsKey, (mutator) => { diff --git a/packages/services/src/Domain/SharedVaults/SharedVaultService.ts b/packages/services/src/Domain/SharedVaults/SharedVaultService.ts index ae3356ce7..da2000731 100644 --- a/packages/services/src/Domain/SharedVaults/SharedVaultService.ts +++ b/packages/services/src/Domain/SharedVaults/SharedVaultService.ts @@ -111,7 +111,9 @@ export class SharedVaultService ) this.eventDisposers.push( - items.addObserver(ContentType.TrustedContact, ({ changed, inserted, source }) => { + items.addObserver(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 { - await this.reprocessCachedInvitesTrustStatusAfterTrustedContactsChange() - for (const contact of contacts) { await this.shareContactWithUserAdministeredSharedVaults(contact) } @@ -328,28 +328,9 @@ export class SharedVaultService } private async reprocessCachedInvitesTrustStatusAfterTrustedContactsChange(): Promise { - 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( - 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 { diff --git a/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts index 78819adb0..6295874aa 100644 --- a/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts +++ b/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts @@ -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 { - const vaultItems = this.items - .allTrackedItems() - .filter((item) => item.key_system_identifier === vault.systemIdentifier) - this.items.removeItemsLocally(vaultItems as AnyItemInterface[]) + const vaultItems = ( + 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 { diff --git a/packages/services/src/Domain/UseCase/RemoveItemsLocally.ts b/packages/services/src/Domain/UseCase/RemoveItemsLocally.ts new file mode 100644 index 000000000..434a67326 --- /dev/null +++ b/packages/services/src/Domain/UseCase/RemoveItemsLocally.ts @@ -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 { + this.items.removeItemsLocally(items) + + await this.storage.deletePayloadsWithUuids(Uuids(items)) + } +} diff --git a/packages/services/src/Domain/User/UserService.ts b/packages/services/src/Domain/User/UserService.ts index 7355e54d6..2d6d9f2c6 100644 --- a/packages/services/src/Domain/User/UserService.ts +++ b/packages/services/src/Domain/User/UserService.ts @@ -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, diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index b48fa7e2b..26002e987 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -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' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index d1cbeb0a0..b5bbf5924 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -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 } diff --git a/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts index a41c21a2a..8b44a4886 100644 --- a/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts +++ b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts @@ -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 { + 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' diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index 36c6bd96b..ecef70616 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -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 } diff --git a/packages/web/src/javascripts/Components/PasswordWizard/FinishStep.tsx b/packages/web/src/javascripts/Components/PasswordWizard/FinishStep.tsx new file mode 100644 index 000000000..344895503 --- /dev/null +++ b/packages/web/src/javascripts/Components/PasswordWizard/FinishStep.tsx @@ -0,0 +1,17 @@ +import { CheckmarkCircle } from '../UIElements/CheckmarkCircle' + +export const FinishStep = () => { + return ( +
+
+
+ +
+
+
Your password has been successfully changed.
+

Ensure you are running the latest version of Standard Notes on all platforms for maximum compatibility.

+
+
+
+ ) +} diff --git a/packages/web/src/javascripts/Components/PasswordWizard/PasswordStep.tsx b/packages/web/src/javascripts/Components/PasswordWizard/PasswordStep.tsx new file mode 100644 index 000000000..89bd666c8 --- /dev/null +++ b/packages/web/src/javascripts/Components/PasswordWizard/PasswordStep.tsx @@ -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('') + const [newPassword, setNewPassword] = useState('') + const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('') + + 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 ( +
+
+ + + + +
+ + + + + +
+ + + + + +
+ ) +} diff --git a/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx b/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx index a4caf5363..ec90f6ad3 100644 --- a/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx +++ b/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx @@ -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 { - private currentPasswordInput = createRef() - 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 { 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 { 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 { } 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 { }).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 (
{ }, ]} > -
+
+ {this.state.step === Steps.PreprocessingStep && ( + + )} + {this.state.step === Steps.PasswordStep && ( -
-
- - - - -
- - - - - -
- - - - - -
- )} - {this.state.step === Steps.FinishStep && ( -
-
Your password has been successfully changed.
-

- Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum - compatibility. -

-
+ )} + + {this.state.step === Steps.FinishStep && }
diff --git a/packages/web/src/javascripts/Components/PasswordWizard/PreprocessingStep.tsx b/packages/web/src/javascripts/Components/PasswordWizard/PreprocessingStep.tsx new file mode 100644 index 000000000..5b76e3773 --- /dev/null +++ b/packages/web/src/javascripts/Components/PasswordWizard/PreprocessingStep.tsx @@ -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(true) + const [isProcessingMessages, setIsProcessingMessages] = useState(true) + const [isProcessingInvites, setIsProcessingInvites] = useState(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 ( +
+ +

Checking for data conflicts...

+
+ ) + } + + if (needsUserConfirmation === 'no') { + return null + } + + return ( +
+

+ 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. +

+
+ ) +} diff --git a/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts b/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts new file mode 100644 index 000000000..a7c5d7361 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts @@ -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 }, +] diff --git a/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesMenuItem.ts b/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesMenuItem.ts new file mode 100644 index 000000000..5b1ba1eb0 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesMenuItem.ts @@ -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 +} diff --git a/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts b/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts new file mode 100644 index 000000000..7742dda61 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts @@ -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 + } +} diff --git a/packages/web/src/javascripts/Components/Preferences/Controller/SelectableMenuItem.ts b/packages/web/src/javascripts/Components/Preferences/Controller/SelectableMenuItem.ts new file mode 100644 index 000000000..19e94ff77 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Controller/SelectableMenuItem.ts @@ -0,0 +1,5 @@ +import { PreferencesMenuItem } from './PreferencesMenuItem' + +export interface SelectableMenuItem extends PreferencesMenuItem { + selected: boolean +} diff --git a/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx b/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx index 5870012f4..7cf196c1a 100644 --- a/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx +++ b/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx @@ -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 = ({ +const PaneSelector: FunctionComponent = ({ menu, viewControllerManager, application, diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx index 8fddeed72..f18b9e4d3 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx @@ -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 = ({ 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 = ({ viewControllerManager }: Props } const deleteItems = async (items: EncryptedItemInterface[]): Promise => { - 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 = ({ 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 ( - - Error decrypting items <span className="ml-1 text-warning">⚠️</span> + <Title className="flex flex-row items-center gap-2"> + <ErrorCircle /> + Error decrypting items {`${erroredItems.length} items are errored and could not be decrypted.`}
@@ -75,7 +85,7 @@ const ErroredItems: FunctionComponent = ({ viewControllerManager }: Props className="mt-3 mr-2 min-w-20" label="Export all" onClick={() => { - void app.getArchiveService().downloadEncryptedItems(erroredItems) + void application.getArchiveService().downloadEncryptedItems(erroredItems) }} /> + ) +} diff --git a/packages/web/src/javascripts/Components/UIElements/ErrorCircle.tsx b/packages/web/src/javascripts/Components/UIElements/ErrorCircle.tsx new file mode 100644 index 000000000..823898ea7 --- /dev/null +++ b/packages/web/src/javascripts/Components/UIElements/ErrorCircle.tsx @@ -0,0 +1,13 @@ +import Icon from '@/Components/Icon/Icon' + +export const ErrorCircle = () => { + return ( + + ) +}