From 25f55e13683e6fd198402f26afd25f10ed44c86f Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 15 May 2024 12:11:06 -0400 Subject: [PATCH 1/8] [PM-7978] Create ForegroundSyncService For Delegating `fullSync` Calls (#9192) * Create ForegroundSyncService For Delegating `fullSync` calls to the background * Relax `isExternalMessage` to Allow For Typed Payload * Null Coalesce The `startListening` Method * Filter To Only External Messages --- .../browser/src/background/main.background.ts | 76 ++++-- .../platform/sync/foreground-sync.service.ts | 79 ++++++ .../platform/sync/sync-service.listener.ts | 25 ++ libs/common/src/platform/messaging/helpers.ts | 4 +- .../src/platform/sync/core-sync.service.ts | 230 +++++++++++++++++ libs/common/src/platform/sync/internal.ts | 1 + .../src/vault/services/sync/sync.service.ts | 243 +++--------------- 7 files changed, 422 insertions(+), 236 deletions(-) create mode 100644 apps/browser/src/platform/sync/foreground-sync.service.ts create mode 100644 apps/browser/src/platform/sync/sync-service.listener.ts create mode 100644 libs/common/src/platform/sync/core-sync.service.ts create mode 100644 libs/common/src/platform/sync/internal.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index c3722f2a480..d5e8fe1da74 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -230,6 +230,8 @@ import { BrowserPlatformUtilsService } from "../platform/services/platform-utils import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider"; import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service"; +import { ForegroundSyncService } from "../platform/sync/foreground-sync.service"; +import { SyncServiceListener } from "../platform/sync/sync-service.listener"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import FilelessImporterBackground from "../tools/background/fileless-importer.background"; @@ -339,6 +341,7 @@ export default class MainBackground { scriptInjectorService: BrowserScriptInjectorService; kdfConfigService: kdfConfigServiceAbstraction; offscreenDocumentService: OffscreenDocumentService; + syncServiceListener: SyncServiceListener; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -792,32 +795,52 @@ export default class MainBackground { this.providerService = new ProviderService(this.stateProvider); - this.syncService = new SyncService( - this.masterPasswordService, - this.accountService, - this.apiService, - this.domainSettingsService, - this.folderService, - this.cipherService, - this.cryptoService, - this.collectionService, - this.messagingService, - this.policyService, - this.sendService, - this.logService, - this.keyConnectorService, - this.stateService, - this.providerService, - this.folderApiService, - this.organizationService, - this.sendApiService, - this.userDecryptionOptionsService, - this.avatarService, - logoutCallback, - this.billingAccountProfileStateService, - this.tokenService, - this.authService, - ); + if (this.popupOnlyContext) { + this.syncService = new ForegroundSyncService( + this.stateService, + this.folderService, + this.folderApiService, + this.messagingService, + this.logService, + this.cipherService, + this.collectionService, + this.apiService, + this.accountService, + this.authService, + this.sendService, + this.sendApiService, + messageListener, + ); + } else { + this.syncService = new SyncService( + this.masterPasswordService, + this.accountService, + this.apiService, + this.domainSettingsService, + this.folderService, + this.cipherService, + this.cryptoService, + this.collectionService, + this.messagingService, + this.policyService, + this.sendService, + this.logService, + this.keyConnectorService, + this.stateService, + this.providerService, + this.folderApiService, + this.organizationService, + this.sendApiService, + this.userDecryptionOptionsService, + this.avatarService, + logoutCallback, + this.billingAccountProfileStateService, + this.tokenService, + this.authService, + ); + + this.syncServiceListener = new SyncServiceListener(this.syncService, messageListener); + } this.eventUploadService = new EventUploadService( this.apiService, this.stateProvider, @@ -1141,6 +1164,7 @@ export default class MainBackground { this.contextMenusBackground?.init(); await this.idleBackground.init(); this.webRequestBackground?.startListening(); + this.syncServiceListener?.startListening(); return new Promise((resolve) => { setTimeout(async () => { diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts new file mode 100644 index 00000000000..3c144316724 --- /dev/null +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -0,0 +1,79 @@ +import { firstValueFrom, timeout } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { + CommandDefinition, + MessageListener, + MessageSender, +} from "@bitwarden/common/platform/messaging"; +import { CoreSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; + +const SYNC_COMPLETED = new CommandDefinition<{ successfully: boolean }>("syncCompleted"); +export const DO_FULL_SYNC = new CommandDefinition<{ + forceSync: boolean; + allowThrowOnError: boolean; +}>("doFullSync"); + +export class ForegroundSyncService extends CoreSyncService { + constructor( + stateService: StateService, + folderService: InternalFolderService, + folderApiService: FolderApiServiceAbstraction, + messageSender: MessageSender, + logService: LogService, + cipherService: CipherService, + collectionService: CollectionService, + apiService: ApiService, + accountService: AccountService, + authService: AuthService, + sendService: InternalSendService, + sendApiService: SendApiService, + private readonly messageListener: MessageListener, + ) { + super( + stateService, + folderService, + folderApiService, + messageSender, + logService, + cipherService, + collectionService, + apiService, + accountService, + authService, + sendService, + sendApiService, + ); + } + + async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise { + this.syncInProgress = true; + try { + const syncCompletedPromise = firstValueFrom( + this.messageListener.messages$(SYNC_COMPLETED).pipe( + timeout({ + first: 10_000, + with: () => { + throw new Error("Timeout while doing a fullSync call."); + }, + }), + ), + ); + this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError }); + const result = await syncCompletedPromise; + return result.successfully; + } finally { + this.syncInProgress = false; + } + } +} diff --git a/apps/browser/src/platform/sync/sync-service.listener.ts b/apps/browser/src/platform/sync/sync-service.listener.ts new file mode 100644 index 00000000000..b9e18accacd --- /dev/null +++ b/apps/browser/src/platform/sync/sync-service.listener.ts @@ -0,0 +1,25 @@ +import { Subscription, concatMap, filter } from "rxjs"; + +import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/messaging"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +import { DO_FULL_SYNC } from "./foreground-sync.service"; + +export class SyncServiceListener { + constructor( + private readonly syncService: SyncService, + private readonly messageListener: MessageListener, + ) {} + + startListening(): Subscription { + return this.messageListener + .messages$(DO_FULL_SYNC) + .pipe( + filter((message) => isExternalMessage(message)), + concatMap(async ({ forceSync, allowThrowOnError }) => { + await this.syncService.fullSync(forceSync, allowThrowOnError); + }), + ) + .subscribe(); + } +} diff --git a/libs/common/src/platform/messaging/helpers.ts b/libs/common/src/platform/messaging/helpers.ts index bf119432e05..ba772e517bc 100644 --- a/libs/common/src/platform/messaging/helpers.ts +++ b/libs/common/src/platform/messaging/helpers.ts @@ -12,8 +12,8 @@ export const getCommand = (commandDefinition: CommandDefinition | string export const EXTERNAL_SOURCE_TAG = Symbol("externalSource"); -export const isExternalMessage = (message: Message) => { - return (message as Record)?.[EXTERNAL_SOURCE_TAG] === true; +export const isExternalMessage = (message: Record) => { + return message?.[EXTERNAL_SOURCE_TAG] === true; }; export const tagAsExternal: MonoTypeOperatorFunction> = map( diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts new file mode 100644 index 00000000000..52c1a51cb82 --- /dev/null +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -0,0 +1,230 @@ +import { firstValueFrom, map, of, switchMap } from "rxjs"; + +import { ApiService } from "../../abstractions/api.service"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { + SyncCipherNotification, + SyncFolderNotification, + SyncSendNotification, +} from "../../models/response/notification.response"; +import { SendData } from "../../tools/send/models/data/send.data"; +import { SendApiService } from "../../tools/send/services/send-api.service.abstraction"; +import { InternalSendService } from "../../tools/send/services/send.service.abstraction"; +import { CipherService } from "../../vault/abstractions/cipher.service"; +import { CollectionService } from "../../vault/abstractions/collection.service"; +import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; +import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; +import { SyncService } from "../../vault/abstractions/sync/sync.service.abstraction"; +import { CipherData } from "../../vault/models/data/cipher.data"; +import { FolderData } from "../../vault/models/data/folder.data"; +import { LogService } from "../abstractions/log.service"; +import { StateService } from "../abstractions/state.service"; +import { MessageSender } from "../messaging"; + +/** + * Core SyncService Logic EXCEPT for fullSync so that implementations can differ. + */ +export abstract class CoreSyncService implements SyncService { + syncInProgress = false; + + constructor( + protected readonly stateService: StateService, + protected readonly folderService: InternalFolderService, + protected readonly folderApiService: FolderApiServiceAbstraction, + protected readonly messageSender: MessageSender, + protected readonly logService: LogService, + protected readonly cipherService: CipherService, + protected readonly collectionService: CollectionService, + protected readonly apiService: ApiService, + protected readonly accountService: AccountService, + protected readonly authService: AuthService, + protected readonly sendService: InternalSendService, + protected readonly sendApiService: SendApiService, + ) {} + + abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; + + async getLastSync(): Promise { + if ((await this.stateService.getUserId()) == null) { + return null; + } + + const lastSync = await this.stateService.getLastSync(); + if (lastSync) { + return new Date(lastSync); + } + + return null; + } + + async setLastSync(date: Date, userId?: string): Promise { + await this.stateService.setLastSync(date.toJSON(), { userId: userId }); + } + + async syncUpsertFolder(notification: SyncFolderNotification, isEdit: boolean): Promise { + this.syncStarted(); + if (await this.stateService.getIsAuthenticated()) { + try { + const localFolder = await this.folderService.get(notification.id); + if ( + (!isEdit && localFolder == null) || + (isEdit && localFolder != null && localFolder.revisionDate < notification.revisionDate) + ) { + const remoteFolder = await this.folderApiService.get(notification.id); + if (remoteFolder != null) { + await this.folderService.upsert(new FolderData(remoteFolder)); + this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id }); + return this.syncCompleted(true); + } + } + } catch (e) { + this.logService.error(e); + } + } + return this.syncCompleted(false); + } + + async syncDeleteFolder(notification: SyncFolderNotification): Promise { + this.syncStarted(); + if (await this.stateService.getIsAuthenticated()) { + await this.folderService.delete(notification.id); + this.messageSender.send("syncedDeletedFolder", { folderId: notification.id }); + this.syncCompleted(true); + return true; + } + return this.syncCompleted(false); + } + + async syncUpsertCipher(notification: SyncCipherNotification, isEdit: boolean): Promise { + this.syncStarted(); + if (await this.stateService.getIsAuthenticated()) { + try { + let shouldUpdate = true; + const localCipher = await this.cipherService.get(notification.id); + if (localCipher != null && localCipher.revisionDate >= notification.revisionDate) { + shouldUpdate = false; + } + + let checkCollections = false; + if (shouldUpdate) { + if (isEdit) { + shouldUpdate = localCipher != null; + checkCollections = true; + } else { + if (notification.collectionIds == null || notification.organizationId == null) { + shouldUpdate = localCipher == null; + } else { + shouldUpdate = false; + checkCollections = true; + } + } + } + + if ( + !shouldUpdate && + checkCollections && + notification.organizationId != null && + notification.collectionIds != null && + notification.collectionIds.length > 0 + ) { + const collections = await this.collectionService.getAll(); + if (collections != null) { + for (let i = 0; i < collections.length; i++) { + if (notification.collectionIds.indexOf(collections[i].id) > -1) { + shouldUpdate = true; + break; + } + } + } + } + + if (shouldUpdate) { + const remoteCipher = await this.apiService.getFullCipherDetails(notification.id); + if (remoteCipher != null) { + await this.cipherService.upsert(new CipherData(remoteCipher)); + this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id }); + return this.syncCompleted(true); + } + } + } catch (e) { + if (e != null && e.statusCode === 404 && isEdit) { + await this.cipherService.delete(notification.id); + this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id }); + return this.syncCompleted(true); + } + } + } + return this.syncCompleted(false); + } + + async syncDeleteCipher(notification: SyncCipherNotification): Promise { + this.syncStarted(); + if (await this.stateService.getIsAuthenticated()) { + await this.cipherService.delete(notification.id); + this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id }); + return this.syncCompleted(true); + } + return this.syncCompleted(false); + } + + async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise { + this.syncStarted(); + const [activeUserId, status] = await firstValueFrom( + this.accountService.activeAccount$.pipe( + switchMap((a) => { + if (a == null) { + of([null, AuthenticationStatus.LoggedOut]); + } + return this.authService.authStatusFor$(a.id).pipe(map((s) => [a.id, s])); + }), + ), + ); + // Process only notifications for currently active user when user is not logged out + // TODO: once send service allows data manipulation of non-active users, this should process any received notification + if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) { + try { + const localSend = await firstValueFrom(this.sendService.get$(notification.id)); + if ( + (!isEdit && localSend == null) || + (isEdit && localSend != null && localSend.revisionDate < notification.revisionDate) + ) { + const remoteSend = await this.sendApiService.getSend(notification.id); + if (remoteSend != null) { + await this.sendService.upsert(new SendData(remoteSend)); + this.messageSender.send("syncedUpsertedSend", { sendId: notification.id }); + return this.syncCompleted(true); + } + } + } catch (e) { + this.logService.error(e); + } + } + return this.syncCompleted(false); + } + + async syncDeleteSend(notification: SyncSendNotification): Promise { + this.syncStarted(); + if (await this.stateService.getIsAuthenticated()) { + await this.sendService.delete(notification.id); + this.messageSender.send("syncedDeletedSend", { sendId: notification.id }); + this.syncCompleted(true); + return true; + } + return this.syncCompleted(false); + } + + // Helpers + + protected syncStarted() { + this.syncInProgress = true; + this.messageSender.send("syncStarted"); + } + + protected syncCompleted(successfully: boolean): boolean { + this.syncInProgress = false; + this.messageSender.send("syncCompleted", { successfully: successfully }); + return successfully; + } +} diff --git a/libs/common/src/platform/sync/internal.ts b/libs/common/src/platform/sync/internal.ts new file mode 100644 index 00000000000..f515e90a07e --- /dev/null +++ b/libs/common/src/platform/sync/internal.ts @@ -0,0 +1 @@ +export { CoreSyncService } from "./core-sync.service"; diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index b591a61e862..172891b08db 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, of, switchMap } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; @@ -17,22 +17,17 @@ import { AvatarService } from "../../../auth/abstractions/avatar.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction"; import { TokenService } from "../../../auth/abstractions/token.service"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; import { DomainsResponse } from "../../../models/response/domains.response"; -import { - SyncCipherNotification, - SyncFolderNotification, - SyncSendNotification, -} from "../../../models/response/notification.response"; import { ProfileResponse } from "../../../models/response/profile.response"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { LogService } from "../../../platform/abstractions/log.service"; -import { MessagingService } from "../../../platform/abstractions/messaging.service"; import { StateService } from "../../../platform/abstractions/state.service"; +import { MessageSender } from "../../../platform/messaging"; import { sequentialize } from "../../../platform/misc/sequentialize"; +import { CoreSyncService } from "../../../platform/sync/core-sync.service"; import { SendData } from "../../../tools/send/models/data/send.data"; import { SendResponse } from "../../../tools/send/models/response/send.response"; import { SendApiService } from "../../../tools/send/services/send-api.service.abstraction"; @@ -40,7 +35,6 @@ import { InternalSendService } from "../../../tools/send/services/send.service.a import { CipherService } from "../../../vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction"; -import { SyncService as SyncServiceAbstraction } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { CipherData } from "../../../vault/models/data/cipher.data"; import { FolderData } from "../../../vault/models/data/folder.data"; import { CipherResponse } from "../../../vault/models/response/cipher.response"; @@ -49,55 +43,51 @@ import { CollectionService } from "../../abstractions/collection.service"; import { CollectionData } from "../../models/data/collection.data"; import { CollectionDetailsResponse } from "../../models/response/collection.response"; -export class SyncService implements SyncServiceAbstraction { - syncInProgress = false; - +export class SyncService extends CoreSyncService { constructor( private masterPasswordService: InternalMasterPasswordServiceAbstraction, - private accountService: AccountService, - private apiService: ApiService, + accountService: AccountService, + apiService: ApiService, private domainSettingsService: DomainSettingsService, - private folderService: InternalFolderService, - private cipherService: CipherService, + folderService: InternalFolderService, + cipherService: CipherService, private cryptoService: CryptoService, - private collectionService: CollectionService, - private messagingService: MessagingService, + collectionService: CollectionService, + messageSender: MessageSender, private policyService: InternalPolicyService, - private sendService: InternalSendService, - private logService: LogService, + sendService: InternalSendService, + logService: LogService, private keyConnectorService: KeyConnectorService, - private stateService: StateService, + stateService: StateService, private providerService: ProviderService, - private folderApiService: FolderApiServiceAbstraction, + folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, - private sendApiService: SendApiService, + sendApiService: SendApiService, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private avatarService: AvatarService, private logoutCallback: (expired: boolean) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, private tokenService: TokenService, - private authService: AuthService, - ) {} - - async getLastSync(): Promise { - if ((await this.stateService.getUserId()) == null) { - return null; - } - - const lastSync = await this.stateService.getLastSync(); - if (lastSync) { - return new Date(lastSync); - } - - return null; - } - - async setLastSync(date: Date, userId?: string): Promise { - await this.stateService.setLastSync(date.toJSON(), { userId: userId }); + authService: AuthService, + ) { + super( + stateService, + folderService, + folderApiService, + messageSender, + logService, + cipherService, + collectionService, + apiService, + accountService, + authService, + sendService, + sendApiService, + ); } @sequentialize(() => "fullSync") - async fullSync(forceSync: boolean, allowThrowOnError = false): Promise { + override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise { this.syncStarted(); const isAuthenticated = await this.stateService.getIsAuthenticated(); if (!isAuthenticated) { @@ -110,6 +100,7 @@ export class SyncService implements SyncServiceAbstraction { needsSync = await this.needsSyncing(forceSync); } catch (e) { if (allowThrowOnError) { + this.syncCompleted(false); throw e; } } @@ -135,6 +126,7 @@ export class SyncService implements SyncServiceAbstraction { return this.syncCompleted(true); } catch (e) { if (allowThrowOnError) { + this.syncCompleted(false); throw e; } else { return this.syncCompleted(false); @@ -142,171 +134,6 @@ export class SyncService implements SyncServiceAbstraction { } } - async syncUpsertFolder(notification: SyncFolderNotification, isEdit: boolean): Promise { - this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { - try { - const localFolder = await this.folderService.get(notification.id); - if ( - (!isEdit && localFolder == null) || - (isEdit && localFolder != null && localFolder.revisionDate < notification.revisionDate) - ) { - const remoteFolder = await this.folderApiService.get(notification.id); - if (remoteFolder != null) { - await this.folderService.upsert(new FolderData(remoteFolder)); - this.messagingService.send("syncedUpsertedFolder", { folderId: notification.id }); - return this.syncCompleted(true); - } - } - } catch (e) { - this.logService.error(e); - } - } - return this.syncCompleted(false); - } - - async syncDeleteFolder(notification: SyncFolderNotification): Promise { - this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { - await this.folderService.delete(notification.id); - this.messagingService.send("syncedDeletedFolder", { folderId: notification.id }); - this.syncCompleted(true); - return true; - } - return this.syncCompleted(false); - } - - async syncUpsertCipher(notification: SyncCipherNotification, isEdit: boolean): Promise { - this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { - try { - let shouldUpdate = true; - const localCipher = await this.cipherService.get(notification.id); - if (localCipher != null && localCipher.revisionDate >= notification.revisionDate) { - shouldUpdate = false; - } - - let checkCollections = false; - if (shouldUpdate) { - if (isEdit) { - shouldUpdate = localCipher != null; - checkCollections = true; - } else { - if (notification.collectionIds == null || notification.organizationId == null) { - shouldUpdate = localCipher == null; - } else { - shouldUpdate = false; - checkCollections = true; - } - } - } - - if ( - !shouldUpdate && - checkCollections && - notification.organizationId != null && - notification.collectionIds != null && - notification.collectionIds.length > 0 - ) { - const collections = await this.collectionService.getAll(); - if (collections != null) { - for (let i = 0; i < collections.length; i++) { - if (notification.collectionIds.indexOf(collections[i].id) > -1) { - shouldUpdate = true; - break; - } - } - } - } - - if (shouldUpdate) { - const remoteCipher = await this.apiService.getFullCipherDetails(notification.id); - if (remoteCipher != null) { - await this.cipherService.upsert(new CipherData(remoteCipher)); - this.messagingService.send("syncedUpsertedCipher", { cipherId: notification.id }); - return this.syncCompleted(true); - } - } - } catch (e) { - if (e != null && e.statusCode === 404 && isEdit) { - await this.cipherService.delete(notification.id); - this.messagingService.send("syncedDeletedCipher", { cipherId: notification.id }); - return this.syncCompleted(true); - } - } - } - return this.syncCompleted(false); - } - - async syncDeleteCipher(notification: SyncCipherNotification): Promise { - this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { - await this.cipherService.delete(notification.id); - this.messagingService.send("syncedDeletedCipher", { cipherId: notification.id }); - return this.syncCompleted(true); - } - return this.syncCompleted(false); - } - - async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise { - this.syncStarted(); - const [activeUserId, status] = await firstValueFrom( - this.accountService.activeAccount$.pipe( - switchMap((a) => { - if (a == null) { - of([null, AuthenticationStatus.LoggedOut]); - } - return this.authService.authStatusFor$(a.id).pipe(map((s) => [a.id, s])); - }), - ), - ); - // Process only notifications for currently active user when user is not logged out - // TODO: once send service allows data manipulation of non-active users, this should process any received notification - if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) { - try { - const localSend = await firstValueFrom(this.sendService.get$(notification.id)); - if ( - (!isEdit && localSend == null) || - (isEdit && localSend != null && localSend.revisionDate < notification.revisionDate) - ) { - const remoteSend = await this.sendApiService.getSend(notification.id); - if (remoteSend != null) { - await this.sendService.upsert(new SendData(remoteSend)); - this.messagingService.send("syncedUpsertedSend", { sendId: notification.id }); - return this.syncCompleted(true); - } - } - } catch (e) { - this.logService.error(e); - } - } - return this.syncCompleted(false); - } - - async syncDeleteSend(notification: SyncSendNotification): Promise { - this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { - await this.sendService.delete(notification.id); - this.messagingService.send("syncedDeletedSend", { sendId: notification.id }); - this.syncCompleted(true); - return true; - } - return this.syncCompleted(false); - } - - // Helpers - - private syncStarted() { - this.syncInProgress = true; - this.messagingService.send("syncStarted"); - } - - private syncCompleted(successfully: boolean): boolean { - this.syncInProgress = false; - this.messagingService.send("syncCompleted", { successfully: successfully }); - return successfully; - } - private async needsSyncing(forceSync: boolean) { if (forceSync) { return true; @@ -365,7 +192,7 @@ export class SyncService implements SyncServiceAbstraction { if (await this.keyConnectorService.userNeedsMigration()) { await this.keyConnectorService.setConvertAccountRequired(true); - this.messagingService.send("convertAccountToKeyConnector"); + this.messageSender.send("convertAccountToKeyConnector"); } else { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises From db2f60b6849833e9a4586d025074ed2064badf16 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 15 May 2024 14:27:15 -0400 Subject: [PATCH 2/8] [AC-2483] Added new Add Access UI to the collection dialog for AC (#9090) * added new Add Access UI to the collection dialog for AC --- .../collection-dialog.component.html | 12 +++++++++++- .../collection-dialog.component.ts | 15 +++++++++++++++ .../src/app/vault/org-vault/vault.component.ts | 1 + apps/web/src/locales/en/messages.json | 6 ++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html index b64ce5bb00e..6adf6bcf8f0 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html @@ -69,6 +69,13 @@ {{ "readOnlyCollectionAccess" | i18n }} + + {{ "grantAddAccessCollectionWarning" | i18n }} + {{ "grantCollectionAccess" | i18n }} {{ "grantCollectionAccessMembersOnly" | i18n @@ -84,7 +91,10 @@
{{ "managePermissionRequired" | i18n }}
diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts index f386665186f..8040cf13cdd 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts @@ -59,6 +59,7 @@ export interface CollectionDialogParams { */ limitNestedCollections?: boolean; readonly?: boolean; + isAddAccessCollection?: boolean; } export interface CollectionDialogResult { @@ -100,6 +101,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { }); protected PermissionMode = PermissionMode; protected showDeleteButton = false; + protected showAddAccessWarning = false; constructor( @Inject(DIALOG_DATA) private params: CollectionDialogParams, @@ -251,6 +253,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.handleFormGroupReadonly(this.dialogReadonly); this.loading = false; + this.showAddAccessWarning = this.handleAddAccessWarning(flexibleCollectionsV1); }, ); } @@ -362,6 +365,18 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private handleAddAccessWarning(flexibleCollectionsV1: boolean): boolean { + if ( + flexibleCollectionsV1 && + !this.organization?.allowAdminAccessToAllCollectionItems && + this.params.isAddAccessCollection + ) { + return true; + } + + return false; + } + private handleFormGroupReadonly(readonly: boolean) { if (readonly) { this.formGroup.controls.name.disable(); diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 95dd9cab16b..c8549e0b88d 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -1222,6 +1222,7 @@ export class VaultComponent implements OnInit, OnDestroy { organizationId: this.organization?.id, initialTab: tab, readonly: readonly, + isAddAccessCollection: c.addAccess, limitNestedCollections: !this.organization.canEditAnyCollection( this.flexibleCollectionsV1Enabled, ), diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9e5ad73a038..3c96dd5df76 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7635,6 +7635,12 @@ "readOnlyCollectionAccess": { "message": "You do not have access to manage this collection." }, + "grantAddAccessCollectionWarningTitle": { + "message": "Missing Can Manage Permissions" + }, + "grantAddAccessCollectionWarning": { + "message": "Grant Can manage permissions to allow full collection management including deletion of collection." + }, "grantCollectionAccess": { "message": "Grant groups or members access to this collection." }, From 385664c9771390f193dff91df7d815cb84ea679e Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 15 May 2024 13:54:05 -0700 Subject: [PATCH 3/8] Add devFlag for the config service retrieval interval when developing (#9006) * Add devFlag for a configurable config retrieval interval when developing * Add Ms suffix to dev flag --- libs/common/src/platform/misc/flags.ts | 1 + .../services/config/default-config.service.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/libs/common/src/platform/misc/flags.ts b/libs/common/src/platform/misc/flags.ts index e0089a5451a..a77a06debb6 100644 --- a/libs/common/src/platform/misc/flags.ts +++ b/libs/common/src/platform/misc/flags.ts @@ -11,6 +11,7 @@ export type SharedFlags = { export type SharedDevFlags = { noopNotifications: boolean; skipWelcomeOnInstall: boolean; + configRetrievalIntervalMs: number; }; function getFlags(envFlags: string | T): T { diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index 71b76363a3b..0a306348d7b 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -1,13 +1,13 @@ import { - NEVER, - Observable, - Subject, combineLatest, firstValueFrom, map, mergeWith, + NEVER, + Observable, of, shareReplay, + Subject, switchMap, tap, } from "rxjs"; @@ -24,10 +24,13 @@ import { ConfigService } from "../../abstractions/config/config.service"; import { ServerConfig } from "../../abstractions/config/server-config"; import { EnvironmentService, Region } from "../../abstractions/environment.service"; import { LogService } from "../../abstractions/log.service"; +import { devFlagEnabled, devFlagValue } from "../../misc/flags"; import { ServerConfigData } from "../../models/data/server-config.data"; import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state"; -export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour +export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs") + ? (devFlagValue("configRetrievalIntervalMs") as number) + : 3_600_000; // 1 hour export type ApiUrl = string; From c19a640557f3b72e5259342f740acb8533b53693 Mon Sep 17 00:00:00 2001 From: Lorenzo Verardo Date: Wed, 15 May 2024 23:15:57 +0200 Subject: [PATCH 4/8] [PM-8059] Clarify warning message (#9141) --- .../change-kdf/change-kdf-confirmation.component.html | 2 +- .../settings/security/change-kdf/change-kdf.component.html | 2 +- apps/web/src/locales/en/messages.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.html b/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.html index 139edd70e0f..8846104278c 100644 --- a/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.html +++ b/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.html @@ -4,7 +4,7 @@
- {{ "changeKdfLoggedOutWarning" | i18n }} + {{ "kdfSettingsChangeLogoutWarning" | i18n }}

{{ "encKeySettings" | i18n }}

-{{ "changeKdfLoggedOutWarning" | i18n }} +{{ "kdfSettingsChangeLogoutWarning" | i18n }}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3c96dd5df76..dd96824ac94 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7014,8 +7014,8 @@ "updateLowKdfIterationsDesc": { "message": "Update your encryption settings to meet new security recommendations and improve account protection." }, - "changeKdfLoggedOutWarning": { - "message": "Proceeding will log you out of all active sessions. You will need to log back in and complete two-step login setup. We recommend exporting your vault before changing your encryption settings to prevent data loss." + "kdfSettingsChangeLogoutWarning": { + "message": "Proceeding will log you out of all active sessions. You will need to log back in and complete two-step login, if any. We recommend exporting your vault before changing your encryption settings to prevent data loss." }, "secretsManager": { "message": "Secrets Manager" From 4ccf920da8d373bcfa6c745fda8d27043ce421bd Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 15 May 2024 17:40:16 -0400 Subject: [PATCH 5/8] [PM-8155] Keep crypto derive dependencies in lockstep (#9191) * Keep derive dependencies in lockstep This reduces emissions in general due to updates of multiple inputs and removes decryption errors due to partially updated dependencies * Fix provider encrypted org keys * Fix provider state test types * Type fixes --- .../domain/encrypted-organization-key.ts | 38 +++++++++------ .../src/platform/services/crypto.service.ts | 47 ++++++++++--------- .../services/key-state/org-keys.state.spec.ts | 30 +++++++----- .../services/key-state/org-keys.state.ts | 35 +++++++++----- .../key-state/provider-keys.state.spec.ts | 7 +-- .../services/key-state/provider-keys.state.ts | 20 ++++---- .../services/key-state/user-key.state.spec.ts | 18 ++----- .../services/key-state/user-key.state.ts | 20 +++----- 8 files changed, 112 insertions(+), 103 deletions(-) diff --git a/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts b/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts index 470fa2317e6..1f8c4e8c42d 100644 --- a/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts +++ b/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts @@ -1,11 +1,11 @@ -import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { OrgKey } from "../../../types/key"; +import { OrgKey, UserPrivateKey } from "../../../types/key"; import { EncryptedOrganizationKeyData } from "../data/encrypted-organization-key.data"; export abstract class BaseEncryptedOrganizationKey { - decrypt: (cryptoService: CryptoService) => Promise; + abstract get encryptedOrganizationKey(): EncString; static fromData(data: EncryptedOrganizationKeyData) { switch (data.type) { @@ -19,22 +19,26 @@ export abstract class BaseEncryptedOrganizationKey { return null; } } + + static isProviderEncrypted( + key: EncryptedOrganizationKey | ProviderEncryptedOrganizationKey, + ): key is ProviderEncryptedOrganizationKey { + return key.toData().type === "provider"; + } } export class EncryptedOrganizationKey implements BaseEncryptedOrganizationKey { constructor(private key: string) {} - async decrypt(cryptoService: CryptoService) { - const activeUserPrivateKey = await cryptoService.getPrivateKey(); - - if (activeUserPrivateKey == null) { - throw new Error("Active user does not have a private key, cannot decrypt organization key."); - } - - const decValue = await cryptoService.rsaDecrypt(this.key, activeUserPrivateKey); + async decrypt(encryptService: EncryptService, privateKey: UserPrivateKey) { + const decValue = await encryptService.rsaDecrypt(this.encryptedOrganizationKey, privateKey); return new SymmetricCryptoKey(decValue) as OrgKey; } + get encryptedOrganizationKey() { + return new EncString(this.key); + } + toData(): EncryptedOrganizationKeyData { return { type: "organization", @@ -49,12 +53,18 @@ export class ProviderEncryptedOrganizationKey implements BaseEncryptedOrganizati private providerId: string, ) {} - async decrypt(cryptoService: CryptoService) { - const providerKey = await cryptoService.getProviderKey(this.providerId); - const decValue = await cryptoService.decryptToBytes(new EncString(this.key), providerKey); + async decrypt(encryptService: EncryptService, providerKeys: Record) { + const decValue = await encryptService.decryptToBytes( + new EncString(this.key), + providerKeys[this.providerId], + ); return new SymmetricCryptoKey(decValue) as OrgKey; } + get encryptedOrganizationKey() { + return new EncString(this.key); + } + toData(): EncryptedOrganizationKeyData { return { type: "provider", diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index fed22e06a02..2813bfb9608 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -1,5 +1,5 @@ import * as bigInt from "big-integer"; -import { Observable, filter, firstValueFrom, map } from "rxjs"; +import { Observable, filter, firstValueFrom, map, zip } from "rxjs"; import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; @@ -97,13 +97,12 @@ export class CryptoService implements CryptoServiceAbstraction { // User Asymmetric Key Pair this.activeUserEncryptedPrivateKeyState = stateProvider.getActive(USER_ENCRYPTED_PRIVATE_KEY); this.activeUserPrivateKeyState = stateProvider.getDerived( - this.activeUserEncryptedPrivateKeyState.combinedState$.pipe( - filter(([_userId, key]) => key != null), + zip(this.activeUserEncryptedPrivateKeyState.state$, this.activeUserKey$).pipe( + filter(([, userKey]) => !!userKey), ), USER_PRIVATE_KEY, { encryptService: this.encryptService, - getUserKey: (userId) => this.getUserKey(userId), }, ); this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null @@ -116,27 +115,34 @@ export class CryptoService implements CryptoServiceAbstraction { ); this.activeUserPublicKey$ = this.activeUserPublicKeyState.state$; // may be null - // Organization keys - this.activeUserEncryptedOrgKeysState = stateProvider.getActive( - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - this.activeUserOrgKeysState = stateProvider.getDerived( - this.activeUserEncryptedOrgKeysState.state$.pipe(filter((keys) => keys != null)), - USER_ORGANIZATION_KEYS, - { cryptoService: this }, - ); - this.activeUserOrgKeys$ = this.activeUserOrgKeysState.state$; // null handled by `derive` function - // Provider keys this.activeUserEncryptedProviderKeysState = stateProvider.getActive( USER_ENCRYPTED_PROVIDER_KEYS, ); this.activeUserProviderKeysState = stateProvider.getDerived( - this.activeUserEncryptedProviderKeysState.state$.pipe(filter((keys) => keys != null)), + zip( + this.activeUserEncryptedProviderKeysState.state$.pipe(filter((keys) => keys != null)), + this.activeUserPrivateKey$, + ).pipe(filter(([, privateKey]) => !!privateKey)), USER_PROVIDER_KEYS, - { encryptService: this.encryptService, cryptoService: this }, + { encryptService: this.encryptService }, ); this.activeUserProviderKeys$ = this.activeUserProviderKeysState.state$; // null handled by `derive` function + + // Organization keys + this.activeUserEncryptedOrgKeysState = stateProvider.getActive( + USER_ENCRYPTED_ORGANIZATION_KEYS, + ); + this.activeUserOrgKeysState = stateProvider.getDerived( + zip( + this.activeUserEncryptedOrgKeysState.state$.pipe(filter((keys) => keys != null)), + this.activeUserPrivateKey$, + this.activeUserProviderKeys$, + ).pipe(filter(([, privateKey]) => !!privateKey)), + USER_ORGANIZATION_KEYS, + { encryptService: this.encryptService }, + ); + this.activeUserOrgKeys$ = this.activeUserOrgKeysState.state$; // null handled by `derive` function } async setUserKey(key: UserKey, userId?: UserId): Promise { @@ -656,17 +662,14 @@ export class CryptoService implements CryptoServiceAbstraction { } try { - const [userId, encPrivateKey] = await firstValueFrom( - this.activeUserEncryptedPrivateKeyState.combinedState$, - ); + const encPrivateKey = await firstValueFrom(this.activeUserEncryptedPrivateKeyState.state$); if (encPrivateKey == null) { return false; } // Can decrypt private key - const privateKey = await USER_PRIVATE_KEY.derive([userId, encPrivateKey], { + const privateKey = await USER_PRIVATE_KEY.derive([encPrivateKey, key], { encryptService: this.encryptService, - getUserKey: () => Promise.resolve(key), }); if (privateKey == null) { diff --git a/libs/common/src/platform/services/key-state/org-keys.state.spec.ts b/libs/common/src/platform/services/key-state/org-keys.state.spec.ts index 6b547a491aa..98e0139cc4d 100644 --- a/libs/common/src/platform/services/key-state/org-keys.state.spec.ts +++ b/libs/common/src/platform/services/key-state/org-keys.state.spec.ts @@ -1,8 +1,8 @@ import { mock } from "jest-mock-extended"; import { makeEncString, makeStaticByteArray } from "../../../../spec"; -import { OrgKey } from "../../../types/key"; -import { CryptoService } from "../../abstractions/crypto.service"; +import { OrgKey, UserPrivateKey } from "../../../types/key"; +import { EncryptService } from "../../abstractions/encrypt.service"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ORGANIZATION_KEYS } from "./org-keys.state"; @@ -30,7 +30,8 @@ describe("encrypted org keys", () => { }); describe("derived decrypted org keys", () => { - const cryptoService = mock(); + const encryptService = mock(); + const userPrivateKey = makeStaticByteArray(64, 3) as UserPrivateKey; const sut = USER_ORGANIZATION_KEYS; afterEach(() => { @@ -65,15 +66,11 @@ describe("derived decrypted org keys", () => { "org-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as OrgKey, }; - const userPrivateKey = makeStaticByteArray(64, 3); - - cryptoService.getPrivateKey.mockResolvedValue(userPrivateKey); - // TODO: How to not have to mock these decryptions. They are internal concerns of EncryptedOrganizationKey - cryptoService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key); - cryptoService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key); + encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key); + encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key); - const result = await sut.derive(encryptedOrgKeys, { cryptoService }); + const result = await sut.derive([encryptedOrgKeys, userPrivateKey, {}], { encryptService }); expect(result).toEqual(decryptedOrgKeys); }); @@ -92,16 +89,23 @@ describe("derived decrypted org keys", () => { }, }; + const providerKeys = { + "provider-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)), + "provider-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)), + }; + const decryptedOrgKeys = { "org-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)) as OrgKey, "org-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as OrgKey, }; // TODO: How to not have to mock these decryptions. They are internal concerns of ProviderEncryptedOrganizationKey - cryptoService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key); - cryptoService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key); + encryptService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key); + encryptService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key); - const result = await sut.derive(encryptedOrgKeys, { cryptoService }); + const result = await sut.derive([encryptedOrgKeys, userPrivateKey, providerKeys], { + encryptService, + }); expect(result).toEqual(decryptedOrgKeys); }); diff --git a/libs/common/src/platform/services/key-state/org-keys.state.ts b/libs/common/src/platform/services/key-state/org-keys.state.ts index f67e64b6538..8a42e242b12 100644 --- a/libs/common/src/platform/services/key-state/org-keys.state.ts +++ b/libs/common/src/platform/services/key-state/org-keys.state.ts @@ -1,10 +1,10 @@ import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "../../../admin-console/models/domain/encrypted-organization-key"; -import { OrganizationId } from "../../../types/guid"; -import { OrgKey } from "../../../types/key"; -import { CryptoService } from "../../abstractions/crypto.service"; +import { OrganizationId, ProviderId } from "../../../types/guid"; +import { OrgKey, ProviderKey, UserPrivateKey } from "../../../types/key"; +import { EncryptService } from "../../abstractions/encrypt.service"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; +import { CRYPTO_DISK, CRYPTO_MEMORY, DeriveDefinition, UserKeyDefinition } from "../../state"; export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record< EncryptedOrganizationKeyData, @@ -14,11 +14,15 @@ export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record< clearOn: ["logout"], }); -export const USER_ORGANIZATION_KEYS = DeriveDefinition.from< - Record, +export const USER_ORGANIZATION_KEYS = new DeriveDefinition< + [ + Record, + UserPrivateKey, + Record, + ], Record, - { cryptoService: CryptoService } ->(USER_ENCRYPTED_ORGANIZATION_KEYS, { + { encryptService: EncryptService } +>(CRYPTO_MEMORY, "organizationKeys", { deserializer: (obj) => { const result: Record = {}; for (const orgId of Object.keys(obj ?? {}) as OrganizationId[]) { @@ -26,14 +30,21 @@ export const USER_ORGANIZATION_KEYS = DeriveDefinition.from< } return result; }, - derive: async (from, { cryptoService }) => { + derive: async ([encryptedOrgKeys, privateKey, providerKeys], { encryptService }) => { const result: Record = {}; - for (const orgId of Object.keys(from ?? {}) as OrganizationId[]) { + for (const orgId of Object.keys(encryptedOrgKeys ?? {}) as OrganizationId[]) { if (result[orgId] != null) { continue; } - const encrypted = BaseEncryptedOrganizationKey.fromData(from[orgId]); - const decrypted = await encrypted.decrypt(cryptoService); + const encrypted = BaseEncryptedOrganizationKey.fromData(encryptedOrgKeys[orgId]); + + let decrypted: OrgKey; + + if (BaseEncryptedOrganizationKey.isProviderEncrypted(encrypted)) { + decrypted = await encrypted.decrypt(encryptService, providerKeys); + } else { + decrypted = await encrypted.decrypt(encryptService, privateKey); + } result[orgId] = decrypted; } diff --git a/libs/common/src/platform/services/key-state/provider-keys.state.spec.ts b/libs/common/src/platform/services/key-state/provider-keys.state.spec.ts index 78e61e03914..ca84d4a6ea1 100644 --- a/libs/common/src/platform/services/key-state/provider-keys.state.spec.ts +++ b/libs/common/src/platform/services/key-state/provider-keys.state.spec.ts @@ -6,7 +6,6 @@ import { ProviderKey, UserPrivateKey } from "../../../types/key"; import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { CryptoService } from "../crypto.service"; import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./provider-keys.state"; @@ -27,7 +26,6 @@ describe("encrypted provider keys", () => { describe("derived decrypted provider keys", () => { const encryptService = mock(); - const cryptoService = mock(); const userPrivateKey = makeStaticByteArray(64, 0) as UserPrivateKey; const sut = USER_PROVIDER_KEYS; @@ -59,9 +57,8 @@ describe("derived decrypted provider keys", () => { encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-1"].key); encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-2"].key); - cryptoService.getPrivateKey.mockResolvedValueOnce(userPrivateKey); - const result = await sut.derive(encryptedProviderKeys, { encryptService, cryptoService }); + const result = await sut.derive([encryptedProviderKeys, userPrivateKey], { encryptService }); expect(result).toEqual(decryptedProviderKeys); }); @@ -69,7 +66,7 @@ describe("derived decrypted provider keys", () => { it("should handle null input values", async () => { const encryptedProviderKeys: Record = null; - const result = await sut.derive(encryptedProviderKeys, { encryptService, cryptoService }); + const result = await sut.derive([encryptedProviderKeys, userPrivateKey], { encryptService }); expect(result).toEqual({}); }); diff --git a/libs/common/src/platform/services/key-state/provider-keys.state.ts b/libs/common/src/platform/services/key-state/provider-keys.state.ts index 776fdc77d8b..dfda71be213 100644 --- a/libs/common/src/platform/services/key-state/provider-keys.state.ts +++ b/libs/common/src/platform/services/key-state/provider-keys.state.ts @@ -1,10 +1,9 @@ import { ProviderId } from "../../../types/guid"; -import { ProviderKey } from "../../../types/key"; +import { ProviderKey, UserPrivateKey } from "../../../types/key"; import { EncryptService } from "../../abstractions/encrypt.service"; import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; -import { CryptoService } from "../crypto.service"; +import { CRYPTO_DISK, CRYPTO_MEMORY, DeriveDefinition, UserKeyDefinition } from "../../state"; export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record( CRYPTO_DISK, @@ -15,11 +14,11 @@ export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record, +export const USER_PROVIDER_KEYS = new DeriveDefinition< + [Record, UserPrivateKey], Record, - { encryptService: EncryptService; cryptoService: CryptoService } // TODO: This should depend on an active user private key observable directly ->(USER_ENCRYPTED_PROVIDER_KEYS, { + { encryptService: EncryptService } +>(CRYPTO_MEMORY, "providerKeys", { deserializer: (obj) => { const result: Record = {}; for (const providerId of Object.keys(obj ?? {}) as ProviderId[]) { @@ -27,14 +26,13 @@ export const USER_PROVIDER_KEYS = DeriveDefinition.from< } return result; }, - derive: async (from, { encryptService, cryptoService }) => { + derive: async ([encryptedProviderKeys, privateKey], { encryptService }) => { const result: Record = {}; - for (const providerId of Object.keys(from ?? {}) as ProviderId[]) { + for (const providerId of Object.keys(encryptedProviderKeys ?? {}) as ProviderId[]) { if (result[providerId] != null) { continue; } - const encrypted = new EncString(from[providerId]); - const privateKey = await cryptoService.getPrivateKey(); + const encrypted = new EncString(encryptedProviderKeys[providerId]); const decrypted = await encryptService.rsaDecrypt(encrypted, privateKey); const providerKey = new SymmetricCryptoKey(decrypted) as ProviderKey; diff --git a/libs/common/src/platform/services/key-state/user-key.state.spec.ts b/libs/common/src/platform/services/key-state/user-key.state.spec.ts index 5c5c5ac70c5..63273f1c795 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.spec.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.spec.ts @@ -1,7 +1,6 @@ import { mock } from "jest-mock-extended"; import { makeStaticByteArray } from "../../../../spec"; -import { UserId } from "../../../types/guid"; import { UserKey, UserPrivateKey, UserPublicKey } from "../../../types/key"; import { CryptoFunctionService } from "../../abstractions/crypto-function.service"; import { EncryptService } from "../../abstractions/encrypt.service"; @@ -70,7 +69,6 @@ describe("User public key", () => { describe("Derived decrypted private key", () => { const sut = USER_PRIVATE_KEY; - const userId = "userId" as UserId; const userKey = mock(); const encryptedPrivateKey = makeEncString().encryptedString; const decryptedPrivateKey = makeStaticByteArray(64, 1); @@ -88,37 +86,31 @@ describe("Derived decrypted private key", () => { }); it("should derive decrypted private key", async () => { - const getUserKey = jest.fn(async () => userKey); const encryptService = mock(); encryptService.decryptToBytes.mockResolvedValue(decryptedPrivateKey); - const result = await sut.derive([userId, encryptedPrivateKey], { + const result = await sut.derive([encryptedPrivateKey, userKey], { encryptService, - getUserKey, }); expect(result).toEqual(decryptedPrivateKey); }); - it("should handle null input values", async () => { - const getUserKey = jest.fn(async () => userKey); + it("should handle null encryptedPrivateKey", async () => { const encryptService = mock(); - const result = await sut.derive([userId, null], { + const result = await sut.derive([null, userKey], { encryptService, - getUserKey, }); expect(result).toEqual(null); }); - it("should handle null user key", async () => { - const getUserKey = jest.fn(async () => null); + it("should handle null userKey", async () => { const encryptService = mock(); - const result = await sut.derive([userId, encryptedPrivateKey], { + const result = await sut.derive([encryptedPrivateKey, null], { encryptService, - getUserKey, }); expect(result).toEqual(null); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index 3df3b2044bd..c2b84d6a247 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -1,4 +1,3 @@ -import { UserId } from "../../../types/guid"; import { UserPrivateKey, UserPublicKey, UserKey } from "../../../types/key"; import { CryptoFunctionService } from "../../abstractions/crypto-function.service"; import { EncryptService } from "../../abstractions/encrypt.service"; @@ -24,20 +23,14 @@ export const USER_ENCRYPTED_PRIVATE_KEY = new UserKeyDefinition }, ); -export const USER_PRIVATE_KEY = DeriveDefinition.fromWithUserId< - EncryptedString, +export const USER_PRIVATE_KEY = new DeriveDefinition< + [EncryptedString, UserKey], UserPrivateKey, - // TODO: update cryptoService to user key directly - { encryptService: EncryptService; getUserKey: (userId: UserId) => Promise } ->(USER_ENCRYPTED_PRIVATE_KEY, { + { encryptService: EncryptService } +>(CRYPTO_MEMORY, "privateKey", { deserializer: (obj) => new Uint8Array(Object.values(obj)) as UserPrivateKey, - derive: async ([userId, encPrivateKeyString], { encryptService, getUserKey }) => { - if (encPrivateKeyString == null) { - return null; - } - - const userKey = await getUserKey(userId); - if (userKey == null) { + derive: async ([encPrivateKeyString, userKey], { encryptService }) => { + if (encPrivateKeyString == null || userKey == null) { return null; } @@ -64,6 +57,7 @@ export const USER_PUBLIC_KEY = DeriveDefinition.from< return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; }, }); + export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", { deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, clearOn: ["logout", "lock"], From e55e3d5b9bf055a4dcae4d6b139295a26409f11c Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 15 May 2024 17:41:04 -0400 Subject: [PATCH 6/8] [PM-8159] [PM-8158] [PM-8156] Swallow multiple offscreen document errors (#9195) * Swallow multiple offscreen document errors The API has race issues with determining if an offscreen document exists (https://groups.google.com/a/chromium.org/g/chromium-extensions/c/s2Wp55bjySE/m/SnjJu1MdAAAJ). However, there are no negative effects of attempting to open multiple other than this throw. * Resolve circular dependency --- .../browser/src/background/main.background.ts | 5 ++-- .../offscreen-document.service.spec.ts | 7 +++++- .../offscreen-document.service.ts | 24 ++++++++++++++----- .../src/popup/services/services.module.ts | 10 ++++---- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index d5e8fe1da74..41f300270eb 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -380,7 +380,8 @@ export default class MainBackground { const logoutCallback = async (expired: boolean, userId?: UserId) => await this.logout(expired, userId); - this.logService = new ConsoleLogService(false); + const isDev = process.env.ENV === "development"; + this.logService = new ConsoleLogService(isDev); this.cryptoFunctionService = new WebCryptoFunctionService(self); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); this.storageService = new BrowserLocalStorageService(); @@ -399,7 +400,7 @@ export default class MainBackground { ), ); - this.offscreenDocumentService = new DefaultOffscreenDocumentService(); + this.offscreenDocumentService = new DefaultOffscreenDocumentService(this.logService); this.platformUtilsService = new BackgroundPlatformUtilsService( this.messagingService, diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts index d6be0a924e5..c9bdd823a52 100644 --- a/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts @@ -1,3 +1,7 @@ +import { mock } from "jest-mock-extended"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + import { DefaultOffscreenDocumentService } from "./offscreen-document.service"; class TestCase { @@ -21,6 +25,7 @@ describe.each([ new TestCase("synchronous callback", () => 42), new TestCase("asynchronous callback", () => Promise.resolve(42)), ])("DefaultOffscreenDocumentService %s", (testCase) => { + const logService = mock(); let sut: DefaultOffscreenDocumentService; const reasons = [chrome.offscreen.Reason.TESTING]; const justification = "justification is testing"; @@ -37,7 +42,7 @@ describe.each([ callback = testCase.callback; chrome.offscreen = api; - sut = new DefaultOffscreenDocumentService(); + sut = new DefaultOffscreenDocumentService(logService); }); afterEach(() => { diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts index da0ca382698..a260e3ca6c8 100644 --- a/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts @@ -1,7 +1,9 @@ +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + export class DefaultOffscreenDocumentService implements DefaultOffscreenDocumentService { private workerCount = 0; - constructor() {} + constructor(private logService: LogService) {} async withDocument( reasons: chrome.offscreen.Reason[], @@ -24,11 +26,21 @@ export class DefaultOffscreenDocumentService implements DefaultOffscreenDocument } private async create(reasons: chrome.offscreen.Reason[], justification: string): Promise { - await chrome.offscreen.createDocument({ - url: "offscreen-document/index.html", - reasons, - justification, - }); + try { + await chrome.offscreen.createDocument({ + url: "offscreen-document/index.html", + reasons, + justification, + }); + } catch (e) { + // gobble multiple offscreen document creation errors + // TODO: remove this when the offscreen document service is fixed PM-8014 + if (e.message === "Only a single offscreen document may be created.") { + this.logService.info("Ignoring offscreen document creation error."); + return; + } + throw e; + } } private async close(): Promise { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 0b9c8f6fe68..32d4adae4a1 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -195,9 +195,11 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: LogService, - useFactory: (platformUtilsService: PlatformUtilsService) => - new ConsoleLogService(platformUtilsService.isDev()), - deps: [PlatformUtilsService], + useFactory: () => { + const isDev = process.env.ENV === "development"; + return new ConsoleLogService(isDev); + }, + deps: [], }), safeProvider({ provide: EnvironmentService, @@ -286,7 +288,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: OffscreenDocumentService, useClass: DefaultOffscreenDocumentService, - deps: [], + deps: [LogService], }), safeProvider({ provide: PlatformUtilsService, From ff19514c27d0d48dff3f5f7b79473ef8807107cd Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 15 May 2024 19:57:59 -0500 Subject: [PATCH 7/8] [PM-7878] PopupSectionHeader component (#9107) * add PopupSectionHeaderComponent * refactor to shorter imports + format * use title as an input * use small icon buttons for section header --- .../popup-section-header.component.html | 11 +++ .../popup-section-header.component.ts | 13 +++ .../popup-section-header.stories.ts | 90 +++++++++++++++++++ apps/browser/src/popup/app.module.ts | 2 + 4 files changed, 116 insertions(+) create mode 100644 apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html create mode 100644 apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts create mode 100644 apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts diff --git a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html new file mode 100644 index 00000000000..6cc7e317e27 --- /dev/null +++ b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html @@ -0,0 +1,11 @@ +
+
+

+ {{ title }} +

+ +
+
+ +
+
diff --git a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts new file mode 100644 index 00000000000..b33a2a0f330 --- /dev/null +++ b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts @@ -0,0 +1,13 @@ +import { Component, Input } from "@angular/core"; + +import { TypographyModule } from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "popup-section-header", + templateUrl: "./popup-section-header.component.html", + imports: [TypographyModule], +}) +export class PopupSectionHeaderComponent { + @Input() title: string; +} diff --git a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts new file mode 100644 index 00000000000..450bfb24226 --- /dev/null +++ b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts @@ -0,0 +1,90 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { + CardComponent, + IconButtonModule, + SectionComponent, + TypographyModule, +} from "@bitwarden/components"; + +import { PopupSectionHeaderComponent } from "./popup-section-header.component"; + +export default { + title: "Browser/Popup Section Header", + component: PopupSectionHeaderComponent, + args: { + title: "Title", + }, + decorators: [ + moduleMetadata({ + imports: [SectionComponent, CardComponent, TypographyModule, IconButtonModule], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const OnlyTitle: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + args: { + title: "Only Title", + }, +}; + +export const TrailingText: Story = { + render: (args) => ({ + props: args, + template: ` + + 13 + + `, + }), + args: { + title: "Trailing Text", + }, +}; + +export const TailingIcon: Story = { + render: (args) => ({ + props: args, + template: ` + + + + `, + }), + args: { + title: "Trailing Icon", + }, +}; + +export const WithSections: Story = { + render: () => ({ + template: ` +
+ + + + + +

Card 1 Content

+
+
+ + + + + +

Card 2 Content

+
+
+
+ `, + }), +}; diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 05158d3295d..74e24433b2c 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -47,6 +47,7 @@ import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.comp import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../platform/popup/layout/popup-page.component"; import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component"; +import { PopupSectionHeaderComponent } from "../platform/popup/popup-section-header/popup-section-header.component"; import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component"; import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component"; @@ -124,6 +125,7 @@ import "../platform/popup/locales"; PopupFooterComponent, PopupHeaderComponent, UserVerificationDialogComponent, + PopupSectionHeaderComponent, ], declarations: [ ActionButtonsComponent, From 07076ebf9de1bb2cf6fda580ed72e7834fb4641e Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 16 May 2024 08:18:58 -0500 Subject: [PATCH 8/8] [PM-7231] Product Switcher within navigation sidebar (#8810) * refactor: move logic for products into a service - This is in preparation for having having the navigation menu show products based off of the same logic. * add extra small font size to tailwind config * remove absolute positioning from toggle width component - it now sits beneath the product switcher * update product switcher to have UI details that are only shown in the navigation pane * add navigation oriented product switcher * integrate navigation product switcher into secrets manager * integrate navigation product switcher into provider console * integrate navigation product switcher into user layout * integrate navigation product switcher into organizations * add translation for "switch" * hide active styles from navigation product switcher * update storybook for product switcher stories * remove unneeded full width style * use protected readonly variable instead of getter * migrate stories to CSF3 * remove double subscription to `moreProducts$` * only use wrapping div in navigation switcher story - less vertical space is taken up * update to satisfies * refactor `navigationUI` to `otherProductOverrides` * move observables to protected readonly * apply margin-top via class on the host component * remove switch text from the navigation product switcher * Allow for the active navigation switcher to be shown * remove xxs font style * remove unneeded module * remove switch from stories * remove defensive nullish coalescing * remove merge leftovers * Defect PM-7899 - show organizations product at the top of the other products list * Defect PM-7951 use attr.icon to keep the icon as an attribute after prod mode is enabled * Defect PM-7948 update path based on the current org * force active styles for navigation items (#9128) * add horizontal margin to icon --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .../organization-layout.component.html | 8 +- .../layouts/organization-layout.component.ts | 2 + .../navigation-switcher.component.html | 35 +++ .../navigation-switcher.component.spec.ts | 194 ++++++++++++++++ .../navigation-switcher.component.ts | 24 ++ .../navigation-switcher.stories.ts | 171 ++++++++++++++ .../product-switcher-content.component.ts | 138 +---------- .../product-switcher.module.ts | 12 +- .../product-switcher.stories.ts | 67 ++++-- .../shared/product-switcher.service.spec.ts | 216 ++++++++++++++++++ .../shared/product-switcher.service.ts | 189 +++++++++++++++ .../src/app/layouts/toggle-width.component.ts | 1 - .../app/layouts/user-layout.component.html | 4 +- .../src/app/layouts/user-layout.component.ts | 2 + apps/web/src/locales/en/messages.json | 6 + .../providers/providers-layout.component.html | 5 +- .../providers/providers-layout.component.ts | 2 + .../secrets-manager/layout/layout.module.ts | 2 + .../layout/navigation.component.html | 4 +- .../src/navigation/nav-item.component.ts | 7 +- .../src/navigation/nav-item.stories.ts | 11 + 21 files changed, 932 insertions(+), 168 deletions(-) create mode 100644 apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html create mode 100644 apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts create mode 100644 apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts create mode 100644 apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts create mode 100644 apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts create mode 100644 apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 2b3be149749..d1a48a78e11 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -1,5 +1,9 @@ -