diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index e043dbfdd2e..37c05a55a3a 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -60,10 +60,18 @@ describe("NotificationBackground", () => { const configService = mock(); const accountService = mock(); + const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ + id: "testId" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + beforeEach(() => { activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; + accountService.activeAccount$ = activeAccountSubject; notificationBackground = new NotificationBackground( autofillService, cipherService, @@ -683,13 +691,6 @@ describe("NotificationBackground", () => { }); describe("saveOrUpdateCredentials", () => { - const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ - id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", - }); - let getDecryptedCipherByIdSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; let updatePasswordSpy: jest.SpyInstance; diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 0947ce1e1da..5c6ff3c2c8c 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -83,6 +83,8 @@ export default class NotificationBackground { getWebVaultUrlForNotification: () => this.getWebVaultUrl(), }; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private autofillService: AutofillService, private cipherService: CipherService, @@ -569,9 +571,7 @@ export default class NotificationBackground { return; } - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.activeUserId$); const cipher = await this.cipherService.encrypt(newCipher, activeUserId); try { @@ -611,10 +611,7 @@ export default class NotificationBackground { return; } - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - + const activeUserId = await firstValueFrom(this.activeUserId$); const cipher = await this.cipherService.encrypt(cipherView, activeUserId); try { // We've only updated the password, no need to broadcast editedCipher message @@ -647,17 +644,15 @@ export default class NotificationBackground { if (Utils.isNullOrWhitespace(folderId) || folderId === "null") { return false; } - - const folders = await firstValueFrom(this.folderService.folderViews$); + const activeUserId = await firstValueFrom(this.activeUserId$); + const folders = await firstValueFrom(this.folderService.folderViews$(activeUserId)); return folders.some((x) => x.id === folderId); } private async getDecryptedCipherById(cipherId: string) { const cipher = await this.cipherService.get(cipherId); if (cipher != null && cipher.type === CipherType.Login) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.activeUserId$); return await cipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), @@ -697,7 +692,8 @@ export default class NotificationBackground { * Returns the first value found from the folder service's folderViews$ observable. */ private async getFolderData() { - return await firstValueFrom(this.folderService.folderViews$); + const activeUserId = await firstValueFrom(this.activeUserId$); + return await firstValueFrom(this.folderService.folderViews$(activeUserId)); } private async getWebVaultUrl(): Promise { diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index e1e921cc3a3..f5daff93815 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -3,7 +3,6 @@ import { Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; 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"; @@ -30,12 +29,12 @@ describe("ForegroundSyncService", () => { const cipherService = mock(); const collectionService = mock(); const apiService = mock(); - const accountService = mock(); + const accountService = mockAccountServiceWith(userId); const authService = mock(); const sendService = mock(); const sendApiService = mock(); const messageListener = mock(); - const stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + const stateProvider = new FakeStateProvider(accountService); const sut = new ForegroundSyncService( stateService, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts index 4e222a554f7..cbec7903031 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts @@ -171,7 +171,7 @@ describe("AddEditFolderDialogComponent", () => { it("deletes the folder", async () => { await component.deleteFolder(); - expect(deleteFolder).toHaveBeenCalledWith(folderView.id); + expect(deleteFolder).toHaveBeenCalledWith(folderView.id, ""); expect(showToast).toHaveBeenCalledWith({ variant: "success", title: null, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts index 5fbd54d9d78..a50403cea2d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -13,7 +13,7 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -67,6 +67,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { name: ["", Validators.required], }); + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); private destroyRef = inject(DestroyRef); constructor( @@ -114,10 +115,10 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { this.folder.name = this.folderForm.controls.name.value; try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId.id); + const activeUserId = await firstValueFrom(this.activeUserId$); + const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); const folder = await this.folderService.encrypt(this.folder, userKey); - await this.folderApiService.save(folder); + await this.folderApiService.save(folder, activeUserId); this.toastService.showToast({ variant: "success", @@ -144,7 +145,8 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { } try { - await this.folderApiService.delete(this.folder.id); + const activeUserId = await firstValueFrom(this.activeUserId$); + await this.folderApiService.delete(this.folder.id, activeUserId); this.toastService.showToast({ variant: "success", title: null, diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 580514de610..0eb91c6cbe2 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -7,9 +7,12 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -32,7 +35,7 @@ describe("VaultPopupListFiltersService", () => { } as unknown as CollectionService; const folderService = { - folderViews$, + folderViews$: () => folderViews$, } as unknown as FolderService; const cipherService = { @@ -60,6 +63,8 @@ describe("VaultPopupListFiltersService", () => { policyAppliesToActiveUser$.next(false); policyService.policyAppliesToActiveUser$.mockClear(); + const accountService = mockAccountServiceWith("userId" as UserId); + collectionService.getAllNested = () => Promise.resolve([]); TestBed.configureTestingModule({ providers: [ @@ -92,6 +97,10 @@ describe("VaultPopupListFiltersService", () => { useValue: { getGlobal: () => ({ state$, update }) }, }, { provide: FormBuilder, useClass: FormBuilder }, + { + provide: AccountService, + useValue: accountService, + }, ], }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 0ab08d01e46..8455fd587d0 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -20,6 +20,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -102,6 +103,8 @@ export class VaultPopupListFiltersService { map((ciphers) => Object.values(ciphers)), ); + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private folderService: FolderService, private cipherService: CipherService, @@ -111,6 +114,7 @@ export class VaultPopupListFiltersService { private formBuilder: FormBuilder, private policyService: PolicyService, private stateProvider: StateProvider, + private accountService: AccountService, ) { this.filterForm.controls.organization.valueChanges .pipe(takeUntilDestroyed()) @@ -264,60 +268,67 @@ export class VaultPopupListFiltersService { /** * Folder array structured to be directly passed to `ChipSelectComponent` */ - folders$: Observable[]> = combineLatest([ - this.filters$.pipe( - distinctUntilChanged( - (previousFilter, currentFilter) => - // Only update the collections when the organizationId filter changes - previousFilter.organization?.id === currentFilter.organization?.id, - ), - ), - this.folderService.folderViews$, - this.cipherViews$, - ]).pipe( - map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { - if (folders.length === 1 && folders[0].id === null) { - // Do not display folder selections when only the "no folder" option is available. - return [filters, [], cipherViews]; - } + folders$: Observable[]> = this.activeUserId$.pipe( + switchMap((userId) => + combineLatest([ + this.filters$.pipe( + distinctUntilChanged( + (previousFilter, currentFilter) => + // Only update the collections when the organizationId filter changes + previousFilter.organization?.id === currentFilter.organization?.id, + ), + ), + this.folderService.folderViews$(userId), + this.cipherViews$, + ]).pipe( + map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { + if (folders.length === 1 && folders[0].id === null) { + // Do not display folder selections when only the "no folder" option is available. + return [filters, [], cipherViews]; + } - // Sort folders by alphabetic name - folders.sort(Utils.getSortFunction(this.i18nService, "name")); - let arrangedFolders = folders; + // Sort folders by alphabetic name + folders.sort(Utils.getSortFunction(this.i18nService, "name")); + let arrangedFolders = folders; - const noFolder = folders.find((f) => f.id === null); + const noFolder = folders.find((f) => f.id === null); - if (noFolder) { - // Update `name` of the "no folder" option to "Items with no folder" - noFolder.name = this.i18nService.t("itemsWithNoFolder"); + if (noFolder) { + // Update `name` of the "no folder" option to "Items with no folder" + const updatedNoFolder = { + ...noFolder, + name: this.i18nService.t("itemsWithNoFolder"), + }; - // Move the "no folder" option to the end of the list - arrangedFolders = [...folders.filter((f) => f.id !== null), noFolder]; - } - return [filters, arrangedFolders, cipherViews]; - }), - map(([filters, folders, cipherViews]) => { - const organizationId = filters.organization?.id ?? null; + // Move the "no folder" option to the end of the list + arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder]; + } + return [filters, arrangedFolders, cipherViews]; + }), + map(([filters, folders, cipherViews]) => { + const organizationId = filters.organization?.id ?? null; - // When no org or "My vault" is selected, return all folders - if (organizationId === null || organizationId === MY_VAULT_ID) { - return folders; - } + // When no org or "My vault" is selected, return all folders + if (organizationId === null || organizationId === MY_VAULT_ID) { + return folders; + } - const orgCiphers = cipherViews.filter((c) => c.organizationId === organizationId); + const orgCiphers = cipherViews.filter((c) => c.organizationId === organizationId); - // Return only the folders that have ciphers within the filtered organization - return folders.filter((f) => orgCiphers.some((oc) => oc.folderId === f.id)); - }), - map((folders) => { - const nestedFolders = this.getAllFoldersNested(folders); - return new DynamicTreeNode({ - fullList: folders, - nestedList: nestedFolders, - }); - }), - map((folders) => - folders.nestedList.map((f) => this.convertToChipSelectOption(f, "bwi-folder")), + // Return only the folders that have ciphers within the filtered organization + return folders.filter((f) => orgCiphers.some((oc) => oc.folderId === f.id)); + }), + map((folders) => { + const nestedFolders = this.getAllFoldersNested(folders); + return new DynamicTreeNode({ + fullList: folders, + nestedList: nestedFolders, + }); + }), + map((folders) => + folders.nestedList.map((f) => this.convertToChipSelectOption(f, "bwi-folder")), + ), + ), ), ); diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts index eecad04613e..9c202e26fef 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts @@ -4,10 +4,13 @@ import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { DialogService } from "@bitwarden/components"; @@ -52,8 +55,9 @@ describe("FoldersV2Component", () => { { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, { provide: LogService, useValue: mock() }, - { provide: FolderService, useValue: { folderViews$ } }, + { provide: FolderService, useValue: { folderViews$: () => folderViews$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, ], }) .overrideComponent(FoldersV2Component, { diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.ts index ce196132f88..b1db949f2ee 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.ts @@ -1,8 +1,10 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { filter, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { @@ -45,18 +47,21 @@ export class FoldersV2Component { folders$: Observable; NoFoldersIcon = VaultIcons.NoFolders; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); constructor( private folderService: FolderService, private dialogService: DialogService, + private accountService: AccountService, ) { - this.folders$ = this.folderService.folderViews$.pipe( + this.folders$ = this.activeUserId$.pipe( + filter((userId): userId is UserId => userId !== null), + switchMap((userId) => this.folderService.folderViews$(userId)), map((folders) => { // Remove the last folder, which is the "no folder" option folder if (folders.length > 0) { return folders.slice(0, folders.length - 1); } - return folders; }), ); diff --git a/apps/browser/src/vault/popup/settings/folders.component.ts b/apps/browser/src/vault/popup/settings/folders.component.ts index edf7fe939e8..1e3f182b43d 100644 --- a/apps/browser/src/vault/popup/settings/folders.component.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.ts @@ -1,7 +1,9 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; -import { map, Observable } from "rxjs"; +import { filter, map, Observable, switchMap } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -12,16 +14,21 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; export class FoldersComponent { folders$: Observable; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private folderService: FolderService, private router: Router, + private accountService: AccountService, ) { - this.folders$ = this.folderService.folderViews$.pipe( + this.folders$ = this.activeUserId$.pipe( + filter((userId): userId is UserId => userId != null), + switchMap((userId) => this.folderService.folderViews$(userId)), map((folders) => { + // Remove the last folder, which is the "no folder" option folder if (folders.length > 0) { - folders = folders.slice(0, folders.length - 1); + return folders.slice(0, folders.length - 1); } - return folders; }), ); diff --git a/apps/browser/src/vault/services/vault-filter.service.ts b/apps/browser/src/vault/services/vault-filter.service.ts index ba1d11f5d27..305c7de487b 100644 --- a/apps/browser/src/vault/services/vault-filter.service.ts +++ b/apps/browser/src/vault/services/vault-filter.service.ts @@ -24,7 +24,7 @@ export class VaultFilterService extends BaseVaultFilterService { collectionService: CollectionService, policyService: PolicyService, stateProvider: StateProvider, - private accountService: AccountService, + accountService: AccountService, ) { super( organizationService, @@ -33,6 +33,7 @@ export class VaultFilterService extends BaseVaultFilterService { collectionService, policyService, stateProvider, + accountService, ); this.vaultFilter.myVaultOnly = false; this.vaultFilter.selectedOrganizationId = null; diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index da7c98a35d6..dd99d03b086 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -24,6 +24,8 @@ import { CipherResponse } from "../vault/models/cipher.response"; import { FolderResponse } from "../vault/models/folder.response"; export class EditCommand { + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private cipherService: CipherService, private folderService: FolderService, @@ -121,12 +123,12 @@ export class EditCommand { cipher.collectionIds = req; try { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); const updatedCipher = await this.cipherService.saveCollectionsWithServer(cipher); const decCipher = await updatedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), + await this.cipherService.getKeyForCipherKeyDecryption( + updatedCipher, + await firstValueFrom(this.activeUserId$), + ), ); const res = new CipherResponse(decCipher); return Response.success(res); @@ -136,7 +138,8 @@ export class EditCommand { } private async editFolder(id: string, req: FolderExport) { - const folder = await this.folderService.getFromState(id); + const activeUserId = await firstValueFrom(this.activeUserId$); + const folder = await this.folderService.getFromState(id, activeUserId); if (folder == null) { return Response.notFound(); } @@ -144,12 +147,11 @@ export class EditCommand { let folderView = await folder.decrypt(); folderView = FolderExport.toView(req, folderView); - const activeUserId = await firstValueFrom(this.accountService.activeAccount$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId.id); + const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); const encFolder = await this.folderService.encrypt(folderView, userKey); try { - await this.folderApiService.save(encFolder); - const updatedFolder = await this.folderService.get(folder.id); + await this.folderApiService.save(encFolder, activeUserId); + const updatedFolder = await this.folderService.get(folder.id, activeUserId); const decFolder = await updatedFolder.decrypt(); const res = new FolderResponse(decFolder); return Response.success(res); diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 1a835befd01..7c3cc7caa9f 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -51,6 +51,8 @@ import { FolderResponse } from "../vault/models/folder.response"; import { DownloadCommand } from "./download.command"; export class GetCommand extends DownloadCommand { + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private cipherService: CipherService, private folderService: FolderService, @@ -113,10 +115,8 @@ export class GetCommand extends DownloadCommand { let decCipher: CipherView = null; if (Utils.isGuid(id)) { const cipher = await this.cipherService.get(id); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); if (cipher != null) { + const activeUserId = await firstValueFrom(this.activeUserId$); decCipher = await cipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), ); @@ -383,13 +383,14 @@ export class GetCommand extends DownloadCommand { private async getFolder(id: string) { let decFolder: FolderView = null; + const activeUserId = await firstValueFrom(this.activeUserId$); if (Utils.isGuid(id)) { - const folder = await this.folderService.getFromState(id); + const folder = await this.folderService.getFromState(id, activeUserId); if (folder != null) { decFolder = await folder.decrypt(); } } else if (id.trim() !== "") { - let folders = await this.folderService.getAllDecryptedFromState(); + let folders = await this.folderService.getAllDecryptedFromState(activeUserId); folders = CliUtils.searchFolders(folders, id); if (folders.length > 1) { return Response.multipleResults(folders.map((f) => f.id)); @@ -551,9 +552,7 @@ export class GetCommand extends DownloadCommand { private async getFingerprint(id: string) { let fingerprint: string[] = null; if (id === "me") { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.activeUserId$); const publicKey = await firstValueFrom(this.keyService.userPublicKey$(activeUserId)); fingerprint = await this.keyService.getFingerprint(activeUserId, publicKey); } else if (Utils.isGuid(id)) { diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 9cb36f71496..92da86b696a 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { OrganizationUserApiService, @@ -12,6 +12,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EventType } from "@bitwarden/common/enums"; import { ListResponse as ApiListResponse } from "@bitwarden/common/models/response/list.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -38,6 +39,7 @@ export class ListCommand { private organizationUserApiService: OrganizationUserApiService, private apiService: ApiService, private eventCollectionService: EventCollectionService, + private accountService: AccountService, ) {} async run(object: string, cmdOptions: Record): Promise { @@ -135,7 +137,10 @@ export class ListCommand { } private async listFolders(options: Options) { - let folders = await this.folderService.getAllDecryptedFromState(); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + let folders = await this.folderService.getAllDecryptedFromState(activeUserId); if (options.search != null && options.search.trim() !== "") { folders = CliUtils.searchFolders(folders, options.search); diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index 71c372ef301..9bd3a2bee5f 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -76,6 +76,7 @@ export class OssServeConfigurator { this.serviceContainer.organizationUserApiService, this.serviceContainer.apiService, this.serviceContainer.eventCollectionService, + this.serviceContainer.accountService, ); this.createCommand = new CreateCommand( this.serviceContainer.cipherService, @@ -115,6 +116,7 @@ export class OssServeConfigurator { this.serviceContainer.folderApiService, this.serviceContainer.billingAccountProfileStateService, this.serviceContainer.cipherAuthorizationService, + this.serviceContainer.accountService, ); this.confirmCommand = new ConfirmCommand( this.serviceContainer.apiService, diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index 1cdf23bcd89..3eb0e68de09 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -113,6 +113,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.organizationUserApiService, this.serviceContainer.apiService, this.serviceContainer.eventCollectionService, + this.serviceContainer.accountService, ); const response = await command.run(object, cmd); @@ -321,6 +322,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.folderApiService, this.serviceContainer.billingAccountProfileStateService, this.serviceContainer.cipherAuthorizationService, + this.serviceContainer.accountService, ); const response = await command.run(object, id, cmd); this.processResponse(response); diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index aa01ec3c491..47e91cb55ff 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -30,6 +30,8 @@ import { CipherResponse } from "./models/cipher.response"; import { FolderResponse } from "./models/folder.response"; export class CreateCommand { + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private cipherService: CipherService, private folderService: FolderService, @@ -86,9 +88,7 @@ export class CreateCommand { } private async createCipher(req: CipherExport) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.activeUserId$); const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId); try { const newCipher = await this.cipherService.createWithServer(cipher); @@ -152,9 +152,7 @@ export class CreateCommand { } try { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.activeUserId$); const updatedCipher = await this.cipherService.saveAttachmentRawWithServer( cipher, fileName, @@ -171,12 +169,12 @@ export class CreateCommand { } private async createFolder(req: FolderExport) { - const activeAccountId = await firstValueFrom(this.accountService.activeAccount$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeAccountId.id); + const activeUserId = await firstValueFrom(this.activeUserId$); + const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); const folder = await this.folderService.encrypt(FolderExport.toView(req), userKey); try { - await this.folderApiService.save(folder); - const newFolder = await this.folderService.get(folder.id); + await this.folderApiService.save(folder, activeUserId); + const newFolder = await this.folderService.get(folder.id, activeUserId); const decFolder = await newFolder.decrypt(); const res = new FolderResponse(decFolder); return Response.success(res); diff --git a/apps/cli/src/vault/delete.command.ts b/apps/cli/src/vault/delete.command.ts index 7f79c89b17c..6b66b8bc7bb 100644 --- a/apps/cli/src/vault/delete.command.ts +++ b/apps/cli/src/vault/delete.command.ts @@ -1,6 +1,7 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -19,6 +20,7 @@ export class DeleteCommand { private folderApiService: FolderApiServiceAbstraction, private accountProfileService: BillingAccountProfileStateService, private cipherAuthorizationService: CipherAuthorizationService, + private accountService: AccountService, ) {} async run(object: string, id: string, cmdOptions: Record): Promise { @@ -103,13 +105,16 @@ export class DeleteCommand { } private async deleteFolder(id: string) { - const folder = await this.folderService.getFromState(id); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const folder = await this.folderService.getFromState(id, activeUserId); if (folder == null) { return Response.notFound(); } try { - await this.folderApiService.delete(id); + await this.folderApiService.delete(id, activeUserId); return Response.success(); } catch (e) { return Response.error(e); diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index 341e44f643b..0ad7eef81be 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -6,7 +6,11 @@ import { mock } from "jest-mock-extended"; import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -29,6 +33,8 @@ describe("EmergencyViewDialogComponent", () => { card: {}, } as CipherView; + const accountService: FakeAccountService = mockAccountServiceWith(Utils.newGuid() as UserId); + beforeEach(async () => { open.mockClear(); close.mockClear(); @@ -43,6 +49,7 @@ describe("EmergencyViewDialogComponent", () => { { provide: DialogService, useValue: { open } }, { provide: DialogRef, useValue: { close } }, { provide: DIALOG_DATA, useValue: { cipher: mockCipher } }, + { provide: AccountService, useValue: accountService }, ], }).compileComponents(); diff --git a/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts index bb5a1c511c6..2361293fe80 100644 --- a/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts +++ b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts @@ -83,7 +83,7 @@ export class MigrateFromLegacyEncryptionComponent { }); if (deleteFolders) { - await this.folderApiService.deleteAll(); + await this.folderApiService.deleteAll(activeUser.id); await this.syncService.fullSync(true, true); await this.submit(); return; diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts index d68e3b9d732..b7f99fb7b44 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts @@ -3,8 +3,9 @@ import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { firstValueFrom, Observable } from "rxjs"; +import { firstValueFrom, map, Observable } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -47,6 +48,8 @@ export class BulkMoveDialogComponent implements OnInit { }); folders$: Observable; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( @Inject(DIALOG_DATA) params: BulkMoveDialogParams, private dialogRef: DialogRef, @@ -55,12 +58,14 @@ export class BulkMoveDialogComponent implements OnInit { private i18nService: I18nService, private folderService: FolderService, private formBuilder: FormBuilder, + private accountService: AccountService, ) { this.cipherIds = params.cipherIds ?? []; } async ngOnInit() { - this.folders$ = this.folderService.folderViews$; + const activeUserId = await firstValueFrom(this.activeUserId$); + this.folders$ = this.folderService.folderViews$(activeUserId); this.formGroup.patchValue({ folderId: (await firstValueFrom(this.folders$))[0].id, }); diff --git a/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts b/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts index 4d181a0510d..88af1ef601b 100644 --- a/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts @@ -61,7 +61,7 @@ export class FolderAddEditComponent extends BaseFolderAddEditComponent { } try { - await this.folderApiService.delete(this.folder.id); + await this.folderApiService.delete(this.folder.id, await firstValueFrom(this.activeUserId$)); this.toastService.showToast({ variant: "success", title: null, @@ -82,10 +82,10 @@ export class FolderAddEditComponent extends BaseFolderAddEditComponent { } try { - const activeAccountId = (await firstValueFrom(this.accountSerivce.activeAccount$)).id; + const activeAccountId = await firstValueFrom(this.activeUserId$); const userKey = await this.keyService.getUserKeyWithLegacySupport(activeAccountId); const folder = await this.folderService.encrypt(this.folder, userKey); - this.formPromise = this.folderApiService.save(folder); + this.formPromise = this.folderApiService.save(folder, activeAccountId); await this.formPromise; this.platformUtilsService.showToast( "success", diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 0386a20adbb..47003d51cae 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -63,7 +63,7 @@ describe("vault filter service", () => { singleOrgPolicy = new ReplaySubject(1); organizationService.memberOrganizations$ = organizations; - folderService.folderViews$ = folderViews; + folderService.folderViews$.mockReturnValue(folderViews); collectionService.decryptedCollections$ = collectionViews; policyService.policyAppliesToActiveUser$ .calledWith(PolicyType.PersonalOwnership) @@ -81,6 +81,7 @@ describe("vault filter service", () => { i18nService, stateProvider, collectionService, + accountService, ); collapsedGroupingsState = stateProvider.activeUser.getFake(COLLAPSED_GROUPINGS); }); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index c4ac3dc2d70..97b44132e60 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -21,6 +21,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -45,6 +46,8 @@ const NestingDelimiter = "/"; @Injectable() export class VaultFilterService implements VaultFilterServiceAbstraction { + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + organizationTree$: Observable> = combineLatest([ this.organizationService.memberOrganizations$, this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg), @@ -57,8 +60,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected _organizationFilter = new BehaviorSubject(null); - filteredFolders$: Observable = this.folderService.folderViews$.pipe( - combineLatestWith(this.cipherService.cipherViews$, this._organizationFilter), + filteredFolders$: Observable = this.activeUserId$.pipe( + switchMap((userId) => + combineLatest([ + this.folderService.folderViews$(userId), + this.cipherService.cipherViews$, + this._organizationFilter, + ]), + ), switchMap(([folders, ciphers, org]) => { return this.filterFolders(folders, ciphers, org); }), @@ -95,6 +104,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected i18nService: I18nService, protected stateProvider: StateProvider, protected collectionService: CollectionService, + protected accountService: AccountService, ) {} async getCollectionNodeFromTree(id: string) { diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index b26c55d46e8..bde9f564c4a 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -5,11 +5,14 @@ import { mock } from "jest-mock-extended"; import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -63,6 +66,7 @@ describe("ViewComponent", () => { useValue: mock(), }, { provide: ConfigService, useValue: mock() }, + { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, { provide: CipherAuthorizationService, useValue: { diff --git a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts index c4ac9d73df7..e2d713649f5 100644 --- a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts +++ b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts @@ -4,6 +4,7 @@ import { map, Observable, ReplaySubject, Subject } from "rxjs"; import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -32,6 +33,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest i18nService: I18nService, stateProvider: StateProvider, collectionService: CollectionService, + accountService: AccountService, ) { super( organizationService, @@ -41,6 +43,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest i18nService, stateProvider, collectionService, + accountService, ); } diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 3a9d0289971..4bd3e9710fb 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -102,6 +102,8 @@ export class AddEditComponent implements OnInit, OnDestroy { private personalOwnershipPolicyAppliesToActiveUser: boolean; private previousCipherId: string; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( @@ -259,12 +261,10 @@ export class AddEditComponent implements OnInit, OnDestroy { const loadedAddEditCipherInfo = await this.loadAddEditCipherInfo(); + const activeUserId = await firstValueFrom(this.activeUserId$); if (this.cipher == null) { if (this.editMode) { const cipher = await this.loadCipher(); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); this.cipher = await cipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), ); @@ -323,7 +323,7 @@ export class AddEditComponent implements OnInit, OnDestroy { this.cipher.login.fido2Credentials = null; } - this.folders$ = this.folderService.folderViews$; + this.folders$ = this.folderService.folderViews$(activeUserId); if (this.editMode && this.previousCipherId !== this.cipherId) { void this.eventCollectionService.collectMany(EventType.Cipher_ClientViewed, [this.cipher]); diff --git a/libs/angular/src/vault/components/folder-add-edit.component.ts b/libs/angular/src/vault/components/folder-add-edit.component.ts index 6d9ae53cc66..205733ba48d 100644 --- a/libs/angular/src/vault/components/folder-add-edit.component.ts +++ b/libs/angular/src/vault/components/folder-add-edit.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { Validators, FormBuilder } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -27,6 +27,8 @@ export class FolderAddEditComponent implements OnInit { deletePromise: Promise; protected componentName = ""; + protected activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + formGroup = this.formBuilder.group({ name: ["", [Validators.required]], }); @@ -59,10 +61,10 @@ export class FolderAddEditComponent implements OnInit { } try { - const activeAccountId = await firstValueFrom(this.accountService.activeAccount$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeAccountId.id); + const activeUserId = await firstValueFrom(this.activeUserId$); + const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); const folder = await this.folderService.encrypt(this.folder, userKey); - this.formPromise = this.folderApiService.save(folder); + this.formPromise = this.folderApiService.save(folder, activeUserId); await this.formPromise; this.platformUtilsService.showToast( "success", @@ -90,7 +92,8 @@ export class FolderAddEditComponent implements OnInit { } try { - this.deletePromise = this.folderApiService.delete(this.folder.id); + const activeUserId = await firstValueFrom(this.activeUserId$); + this.deletePromise = this.folderApiService.delete(this.folder.id, activeUserId); await this.deletePromise; this.platformUtilsService.showToast("success", null, this.i18nService.t("deletedFolder")); this.onDeletedFolder.emit(this.folder); @@ -107,8 +110,10 @@ export class FolderAddEditComponent implements OnInit { if (this.editMode) { this.editMode = true; this.title = this.i18nService.t("editFolder"); - const folder = await this.folderService.get(this.folderId); - this.folder = await folder.decrypt(); + const activeUserId = await firstValueFrom(this.activeUserId$); + this.folder = await firstValueFrom( + this.folderService.getDecrypted$(this.folderId, activeUserId), + ); } else { this.title = this.i18nService.t("addFolder"); } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index a6b9a571730..6bea4cd6150 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -79,6 +79,8 @@ export class ViewComponent implements OnDestroy, OnInit { private previousCipherId: string; private passwordReprompted = false; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( @@ -141,9 +143,7 @@ export class ViewComponent implements OnDestroy, OnInit { this.cleanUp(); const cipher = await this.cipherService.get(this.cipherId); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.activeUserId$); this.cipher = await cipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), ); @@ -158,7 +158,7 @@ export class ViewComponent implements OnDestroy, OnInit { if (this.cipher.folderId) { this.folder = await ( - await firstValueFrom(this.folderService.folderViews$) + await firstValueFrom(this.folderService.folderViews$(activeUserId)) ).find((f) => f.id == this.cipher.folderId); } diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index a2b624876be..dd0b49f356a 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; -import { firstValueFrom, from, map, mergeMap, Observable } from "rxjs"; +import { firstValueFrom, from, map, mergeMap, Observable, switchMap } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { @@ -11,6 +11,7 @@ import { import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -32,6 +33,8 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti private readonly collapsedGroupings$: Observable> = this.collapsedGroupingsState.state$.pipe(map((c) => new Set(c))); + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( protected organizationService: OrganizationService, protected folderService: FolderService, @@ -39,6 +42,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti protected collectionService: CollectionService, protected policyService: PolicyService, protected stateProvider: StateProvider, + protected accountService: AccountService, ) {} async storeCollapsedFilterNodes(collapsedFilterNodes: Set): Promise { @@ -81,7 +85,8 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti }); }; - return this.folderService.folderViews$.pipe( + return this.activeUserId$.pipe( + switchMap((userId) => this.folderService.folderViews$(userId)), mergeMap((folders) => from(transformation(folders))), ); } @@ -126,8 +131,9 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti } async getFolderNested(id: string): Promise> { + const activeUserId = await firstValueFrom(this.activeUserId$); const folders = await this.getAllFoldersNested( - await firstValueFrom(this.folderService.folderViews$), + await firstValueFrom(this.folderService.folderViews$(activeUserId)), ); return ServiceUtils.getTreeNodeObjectFromList(folders, id) as TreeNode; } diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index ce9eb5a15c0..1ed5227cb13 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -150,6 +150,9 @@ export const COLLECTION_DATA = new StateDefinition("collection", "disk", { web: "memory", }); export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" }); +export const FOLDER_MEMORY = new StateDefinition("decryptedFolders", "memory", { + browser: "memory-large-object", +}); export const VAULT_FILTER_DISK = new StateDefinition("vaultFilter", "disk", { web: "disk-local", }); diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 180767ccd16..cfa9030c9de 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -85,18 +85,25 @@ export abstract class CoreSyncService implements SyncService { await this.stateProvider.getUser(userId, LAST_SYNC_DATE).update(() => date); } - async syncUpsertFolder(notification: SyncFolderNotification, isEdit: boolean): Promise { + async syncUpsertFolder( + notification: SyncFolderNotification, + isEdit: boolean, + userId: UserId, + ): Promise { this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { + + const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); + + if (authStatus >= AuthenticationStatus.Locked) { try { - const localFolder = await this.folderService.get(notification.id); + const localFolder = await this.folderService.get(notification.id, userId); 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)); + await this.folderService.upsert(new FolderData(remoteFolder), userId); this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id }); return this.syncCompleted(true); } @@ -108,10 +115,13 @@ export abstract class CoreSyncService implements SyncService { return this.syncCompleted(false); } - async syncDeleteFolder(notification: SyncFolderNotification): Promise { + async syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise { this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { - await this.folderService.delete(notification.id); + + const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); + + if (authStatus >= AuthenticationStatus.Locked) { + await this.folderService.delete(notification.id, userId); this.messageSender.send("syncedDeletedFolder", { folderId: notification.id }); this.syncCompleted(true); return true; diff --git a/libs/common/src/platform/sync/sync.service.ts b/libs/common/src/platform/sync/sync.service.ts index 733b7beaff5..6763e01cab7 100644 --- a/libs/common/src/platform/sync/sync.service.ts +++ b/libs/common/src/platform/sync/sync.service.ts @@ -56,8 +56,9 @@ export abstract class SyncService { abstract syncUpsertFolder( notification: SyncFolderNotification, isEdit: boolean, + userId: UserId, ): Promise; - abstract syncDeleteFolder(notification: SyncFolderNotification): Promise; + abstract syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise; abstract syncUpsertCipher( notification: SyncCipherNotification, isEdit: boolean, diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts index 6f7c5c9f262..4a14332af8a 100644 --- a/libs/common/src/services/notifications.service.ts +++ b/libs/common/src/services/notifications.service.ts @@ -168,10 +168,14 @@ export class NotificationsService implements NotificationsServiceAbstraction { await this.syncService.syncUpsertFolder( notification.payload as SyncFolderNotification, notification.type === NotificationType.SyncFolderUpdate, + payloadUserId, ); break; case NotificationType.SyncFolderDelete: - await this.syncService.syncDeleteFolder(notification.payload as SyncFolderNotification); + await this.syncService.syncDeleteFolder( + notification.payload as SyncFolderNotification, + payloadUserId, + ); break; case NotificationType.SyncVault: case NotificationType.SyncCiphers: diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts index 71341a98a62..1350010f849 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts @@ -334,7 +334,7 @@ describe("VaultTimeoutService", () => { // Active users should have additional steps ran expect(searchService.clearIndex).toHaveBeenCalled(); - expect(folderService.clearCache).toHaveBeenCalled(); + expect(folderService.clearDecryptedFolderState).toHaveBeenCalled(); expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index f6ad0f17e9a..f465174bf40 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -135,10 +135,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { if (userId == null || userId === currentUserId) { await this.searchService.clearIndex(); - await this.folderService.clearCache(); await this.collectionService.clearActiveUserCache(); } + await this.folderService.clearDecryptedFolderState(userId); await this.masterPasswordService.clearMasterKey(lockingUserId); await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId }); diff --git a/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts index a732acfa991..859f2183edb 100644 --- a/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts @@ -1,11 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { UserId } from "@bitwarden/common/types/guid"; + import { Folder } from "../../models/domain/folder"; import { FolderResponse } from "../../models/response/folder.response"; export class FolderApiServiceAbstraction { - save: (folder: Folder) => Promise; - delete: (id: string) => Promise; + save: (folder: Folder, userId: UserId) => Promise; + delete: (id: string, userId: UserId) => Promise; get: (id: string) => Promise; - deleteAll: () => Promise; + deleteAll: (userId: UserId) => Promise; } diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index c5722e0f57b..b7241e3ae37 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -13,23 +13,27 @@ import { FolderWithIdRequest } from "../../models/request/folder-with-id.request import { FolderView } from "../../models/view/folder.view"; export abstract class FolderService implements UserKeyRotationDataProvider { - folders$: Observable; - folderViews$: Observable; + folders$: (userId: UserId) => Observable; + folderViews$: (userId: UserId) => Observable; - clearCache: () => Promise; + clearDecryptedFolderState: (userId: UserId) => Promise; encrypt: (model: FolderView, key: SymmetricCryptoKey) => Promise; - get: (id: string) => Promise; - getDecrypted$: (id: string) => Observable; - getAllFromState: () => Promise; + get: (id: string, userId: UserId) => Promise; + getDecrypted$: (id: string, userId: UserId) => Observable; + /** + * @deprecated Use firstValueFrom(folders$) directly instead + * @param userId The user id + * @returns Promise of folders array + */ + getAllFromState: (userId: UserId) => Promise; /** * @deprecated Only use in CLI! */ - getFromState: (id: string) => Promise; + getFromState: (id: string, userId: UserId) => Promise; /** * @deprecated Only use in CLI! */ - getAllDecryptedFromState: () => Promise; - decryptFolders: (folders: Folder[]) => Promise; + getAllDecryptedFromState: (userId: UserId) => Promise; /** * Returns user folders re-encrypted with the new user key. * @param originalUserKey the original user key @@ -46,8 +50,8 @@ export abstract class FolderService implements UserKeyRotationDataProvider Promise; + upsert: (folder: FolderData | FolderData[], userId: UserId) => Promise; replace: (folders: { [id: string]: FolderData }, userId: UserId) => Promise; - clear: (userId?: string) => Promise; - delete: (id: string | string[]) => Promise; + clear: (userId: UserId) => Promise; + delete: (id: string | string[], userId: UserId) => Promise; } diff --git a/libs/common/src/vault/services/folder/folder-api.service.ts b/libs/common/src/vault/services/folder/folder-api.service.ts index e46df37c176..24831393668 100644 --- a/libs/common/src/vault/services/folder/folder-api.service.ts +++ b/libs/common/src/vault/services/folder/folder-api.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "@bitwarden/common/types/guid"; + import { ApiService } from "../../../abstractions/api.service"; import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction"; @@ -12,7 +14,7 @@ export class FolderApiService implements FolderApiServiceAbstraction { private apiService: ApiService, ) {} - async save(folder: Folder): Promise { + async save(folder: Folder, userId: UserId): Promise { const request = new FolderRequest(folder); let response: FolderResponse; @@ -24,17 +26,17 @@ export class FolderApiService implements FolderApiServiceAbstraction { } const data = new FolderData(response); - await this.folderService.upsert(data); + await this.folderService.upsert(data, userId); } - async delete(id: string): Promise { + async delete(id: string, userId: UserId): Promise { await this.deleteFolder(id); - await this.folderService.delete(id); + await this.folderService.delete(id, userId); } - async deleteAll(): Promise { + async deleteAll(userId: UserId): Promise { await this.apiService.send("DELETE", "/folders/all", null, true, false); - await this.folderService.clear(); + await this.folderService.clear(userId); } async get(id: string): Promise { diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 193d0e85e61..612cd83d99b 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -1,10 +1,10 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; -import { makeStaticByteArray } from "../../../../spec"; +import { makeEncString } from "../../../../spec"; import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service"; -import { FakeActiveUserState } from "../../../../spec/fake-state"; +import { FakeSingleUserState } from "../../../../spec/fake-state"; import { FakeStateProvider } from "../../../../spec/fake-state-provider"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; @@ -17,7 +17,7 @@ import { CipherService } from "../../abstractions/cipher.service"; import { FolderData } from "../../models/data/folder.data"; import { FolderView } from "../../models/view/folder.view"; import { FolderService } from "../../services/folder/folder.service"; -import { FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state"; +import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state"; describe("Folder Service", () => { let folderService: FolderService; @@ -30,7 +30,7 @@ describe("Folder Service", () => { const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; - let folderState: FakeActiveUserState>; + let folderState: FakeSingleUserState>; beforeEach(() => { keyService = mock(); @@ -42,11 +42,9 @@ describe("Folder Service", () => { stateProvider = new FakeStateProvider(accountService); i18nService.collator = new Intl.Collator("en"); + i18nService.t.mockReturnValue("No Folder"); - keyService.hasUserKey.mockResolvedValue(true); - keyService.getUserKeyWithLegacySupport.mockResolvedValue( - new SymmetricCryptoKey(makeStaticByteArray(32)) as UserKey, - ); + keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); encryptService.decryptToUtf8.mockResolvedValue("DEC"); folderService = new FolderService( @@ -57,10 +55,53 @@ describe("Folder Service", () => { stateProvider, ); - folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS); + folderState = stateProvider.singleUser.getFake(mockUserId, FOLDER_ENCRYPTED_FOLDERS); // Initial state - folderState.nextState({ "1": folderData("1", "test") }); + folderState.nextState({ "1": folderData("1") }); + }); + + describe("folders$", () => { + it("emits encrypted folders from state", async () => { + const folder1 = folderData("1"); + const folder2 = folderData("2"); + + await stateProvider.setUserState( + FOLDER_ENCRYPTED_FOLDERS, + Object.fromEntries([folder1, folder2].map((f) => [f.id, f])), + mockUserId, + ); + + const result = await firstValueFrom(folderService.folders$(mockUserId)); + + expect(result.length).toBe(2); + expect(result).toIncludeAllPartialMembers([ + { id: "1", name: makeEncString("ENC_STRING_1") }, + { id: "2", name: makeEncString("ENC_STRING_2") }, + ]); + }); + }); + + describe("folderView$", () => { + it("emits decrypted folders from state", async () => { + const folder1 = folderData("1"); + const folder2 = folderData("2"); + + await stateProvider.setUserState( + FOLDER_ENCRYPTED_FOLDERS, + Object.fromEntries([folder1, folder2].map((f) => [f.id, f])), + mockUserId, + ); + + const result = await firstValueFrom(folderService.folderViews$(mockUserId)); + + expect(result.length).toBe(3); + expect(result).toIncludeAllPartialMembers([ + { id: "1", name: "DEC" }, + { id: "2", name: "DEC" }, + { name: "No Folder" }, + ]); + }); }); it("encrypt", async () => { @@ -83,105 +124,83 @@ describe("Folder Service", () => { describe("get", () => { it("exists", async () => { - const result = await folderService.get("1"); + const result = await folderService.get("1", mockUserId); expect(result).toEqual({ id: "1", - name: { - encryptedString: "test", - encryptionType: 0, - }, + name: makeEncString("ENC_STRING_" + 1), revisionDate: null, }); }); it("not exists", async () => { - const result = await folderService.get("2"); + const result = await folderService.get("2", mockUserId); expect(result).toBe(undefined); }); }); it("upsert", async () => { - await folderService.upsert(folderData("2", "test 2")); + await folderService.upsert(folderData("2"), mockUserId); - expect(await firstValueFrom(folderService.folders$)).toEqual([ + expect(await firstValueFrom(folderService.folders$(mockUserId))).toEqual([ { id: "1", - name: { - encryptedString: "test", - encryptionType: 0, - }, + name: makeEncString("ENC_STRING_" + 1), revisionDate: null, }, { id: "2", - name: { - encryptedString: "test 2", - encryptionType: 0, - }, + name: makeEncString("ENC_STRING_" + 2), revisionDate: null, }, ]); }); it("replace", async () => { - await folderService.replace({ "2": folderData("2", "test 2") }, mockUserId); + await folderService.replace({ "4": folderData("4") }, mockUserId); - expect(await firstValueFrom(folderService.folders$)).toEqual([ + expect(await firstValueFrom(folderService.folders$(mockUserId))).toEqual([ { - id: "2", - name: { - encryptedString: "test 2", - encryptionType: 0, - }, + id: "4", + name: makeEncString("ENC_STRING_" + 4), revisionDate: null, }, ]); }); it("delete", async () => { - await folderService.delete("1"); + await folderService.delete("1", mockUserId); - expect((await firstValueFrom(folderService.folders$)).length).toBe(0); + expect((await firstValueFrom(folderService.folders$(mockUserId))).length).toBe(0); }); - it("clearCache", async () => { - await folderService.clearCache(); + describe("clearDecryptedFolderState", () => { + it("null userId", async () => { + await expect(folderService.clearDecryptedFolderState(null)).rejects.toThrow( + "User ID is required.", + ); + }); + + it("userId provided", async () => { + await folderService.clearDecryptedFolderState(mockUserId); - expect((await firstValueFrom(folderService.folders$)).length).toBe(1); - expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0); + expect((await firstValueFrom(folderService.folders$(mockUserId))).length).toBe(1); + expect( + (await firstValueFrom(stateProvider.getUserState$(FOLDER_DECRYPTED_FOLDERS, mockUserId))) + .length, + ).toBe(0); + }); }); - describe("clear", () => { - it("null userId", async () => { - await folderService.clear(); + it("clear", async () => { + await folderService.clear(mockUserId); - expect((await firstValueFrom(folderService.folders$)).length).toBe(0); - expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0); - }); + expect((await firstValueFrom(folderService.folders$(mockUserId))).length).toBe(0); - /** - * TODO: Fix this test to address the problem where the fakes for the active user state is not - * updated as expected - */ - // it("matching userId", async () => { - // stateService.getUserId.mockResolvedValue("1"); - // await folderService.clear("1" as UserId); - - // expect((await firstValueFrom(folderService.folders$)).length).toBe(0); - // }); - - /** - * TODO: Fix this test to address the problem where the fakes for the active user state is not - * updated as expected - */ - // it("mismatching userId", async () => { - // await folderService.clear("12" as UserId); - - // expect((await firstValueFrom(folderService.folders$)).length).toBe(1); - // expect((await firstValueFrom(folderService.folderViews$)).length).toBe(2); - // }); + const folderViews = await firstValueFrom(folderService.folderViews$(mockUserId)); + expect(folderViews.length).toBe(1); + expect(folderViews[0].id).toBeNull(); // Should be the "No Folder" folder }); describe("getRotatedData", () => { @@ -207,10 +226,10 @@ describe("Folder Service", () => { }); }); - function folderData(id: string, name: string) { + function folderData(id: string) { const data = new FolderData({} as any); data.id = id; - data.name = name; + data.name = makeEncString("ENC_STRING_" + data.id).encryptedString; return data; } diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 07b162b4f14..3aac5374fcb 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -1,14 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Observable, firstValueFrom, map, shareReplay } from "rxjs"; +import { Observable, Subject, firstValueFrom, map, shareReplay, switchMap, merge } from "rxjs"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state"; +import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { CipherService } from "../../../vault/abstractions/cipher.service"; @@ -21,11 +21,18 @@ import { FolderWithIdRequest } from "../../models/request/folder-with-id.request import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state"; export class FolderService implements InternalFolderServiceAbstraction { - folders$: Observable; - folderViews$: Observable; + /** + * Ensures we reuse the same observable stream for each userId rather than + * creating a new one on each folderViews$ call. + */ + private folderViewCache = new Map>(); - private encryptedFoldersState: ActiveUserState>; - private decryptedFoldersState: DerivedState; + /** + * Used to force the folderviews$ Observable to re-emit with a provided value. + * Required because shareReplay with refCount: false maintains last emission. + * Used during cleanup to force emit empty arrays, ensuring stale data isn't retained. + */ + private forceFolderViews: Record> = {}; constructor( private keyService: KeyService, @@ -33,23 +40,44 @@ export class FolderService implements InternalFolderServiceAbstraction { private i18nService: I18nService, private cipherService: CipherService, private stateProvider: StateProvider, - ) { - this.encryptedFoldersState = this.stateProvider.getActive(FOLDER_ENCRYPTED_FOLDERS); - this.decryptedFoldersState = this.stateProvider.getDerived( - this.encryptedFoldersState.state$, - FOLDER_DECRYPTED_FOLDERS, - { folderService: this, keyService: this.keyService }, - ); + ) {} - this.folders$ = this.encryptedFoldersState.state$.pipe( - map((folderData) => Object.values(folderData).map((f) => new Folder(f))), - ); + folders$(userId: UserId): Observable { + return this.encryptedFoldersState(userId).state$.pipe( + map((folders) => { + if (folders == null) { + return []; + } - this.folderViews$ = this.decryptedFoldersState.state$; + return Object.values(folders).map((f) => new Folder(f)); + }), + ); } - async clearCache(): Promise { - await this.decryptedFoldersState.forceValue([]); + /** + * Returns an Observable of decrypted folder views for the given userId. + * Uses folderViewCache to maintain a single Observable instance per user, + * combining normal folder state updates with forced updates. + */ + folderViews$(userId: UserId): Observable { + if (!this.folderViewCache.has(userId)) { + if (!this.forceFolderViews[userId]) { + this.forceFolderViews[userId] = new Subject(); + } + + const observable = merge( + this.forceFolderViews[userId], + this.encryptedFoldersState(userId).state$.pipe( + switchMap((folderData) => { + return this.decryptFolders(userId, folderData); + }), + ), + ).pipe(shareReplay({ refCount: false, bufferSize: 1 })); + + this.folderViewCache.set(userId, observable); + } + + return this.folderViewCache.get(userId); } // TODO: This should be moved to EncryptService or something @@ -60,29 +88,29 @@ export class FolderService implements InternalFolderServiceAbstraction { return folder; } - async get(id: string): Promise { - const folders = await firstValueFrom(this.folders$); + async get(id: string, userId: UserId): Promise { + const folders = await firstValueFrom(this.folders$(userId)); return folders.find((folder) => folder.id === id); } - getDecrypted$(id: string): Observable { - return this.folderViews$.pipe( + getDecrypted$(id: string, userId: UserId): Observable { + return this.folderViews$(userId).pipe( map((folders) => folders.find((folder) => folder.id === id)), shareReplay({ refCount: true, bufferSize: 1 }), ); } - async getAllFromState(): Promise { - return await firstValueFrom(this.folders$); + async getAllFromState(userId: UserId): Promise { + return await firstValueFrom(this.folders$(userId)); } /** * @deprecated For the CLI only * @param id id of the folder */ - async getFromState(id: string): Promise { - const folder = await this.get(id); + async getFromState(id: string, userId: UserId): Promise { + const folder = await this.get(id, userId); if (!folder) { return null; } @@ -93,12 +121,13 @@ export class FolderService implements InternalFolderServiceAbstraction { /** * @deprecated Only use in CLI! */ - async getAllDecryptedFromState(): Promise { - return await firstValueFrom(this.folderViews$); + async getAllDecryptedFromState(userId: UserId): Promise { + return await firstValueFrom(this.folderViews$(userId)); } - async upsert(folderData: FolderData | FolderData[]): Promise { - await this.encryptedFoldersState.update((folders) => { + async upsert(folderData: FolderData | FolderData[], userId: UserId): Promise { + await this.clearDecryptedFolderState(userId); + await this.encryptedFoldersState(userId).update((folders) => { if (folders == null) { folders = {}; } @@ -120,24 +149,31 @@ export class FolderService implements InternalFolderServiceAbstraction { if (!folders) { return; } - + await this.clearDecryptedFolderState(userId); await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => { const newFolders: Record = { ...folders }; return newFolders; }); } - async clear(userId?: UserId): Promise { + async clearDecryptedFolderState(userId: UserId): Promise { if (userId == null) { - await this.encryptedFoldersState.update(() => ({})); - await this.decryptedFoldersState.forceValue([]); - } else { - await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => ({})); + throw new Error("User ID is required."); } + + await this.setDecryptedFolders([], userId); + } + + async clear(userId: UserId): Promise { + this.forceFolderViews[userId]?.next([]); + + await this.encryptedFoldersState(userId).update(() => ({})); + await this.clearDecryptedFolderState(userId); } - async delete(id: string | string[]): Promise { - await this.encryptedFoldersState.update((folders) => { + async delete(id: string | string[], userId: UserId): Promise { + await this.clearDecryptedFolderState(userId); + await this.encryptedFoldersState(userId).update((folders) => { if (folders == null) { return; } @@ -164,25 +200,11 @@ export class FolderService implements InternalFolderServiceAbstraction { } } if (updates.length > 0) { - // 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 - this.cipherService.upsert(updates.map((c) => c.toCipherData())); + await this.cipherService.upsert(updates.map((c) => c.toCipherData())); } } } - async decryptFolders(folders: Folder[]) { - const decryptFolderPromises = folders.map((f) => f.decrypt()); - const decryptedFolders = await Promise.all(decryptFolderPromises); - - decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name")); - - const noneFolder = new FolderView(); - noneFolder.name = this.i18nService.t("noneFolder"); - decryptedFolders.push(noneFolder); - return decryptedFolders; - } - async getRotatedData( originalUserKey: UserKey, newUserKey: UserKey, @@ -193,7 +215,7 @@ export class FolderService implements InternalFolderServiceAbstraction { } let encryptedFolders: FolderWithIdRequest[] = []; - const folders = await firstValueFrom(this.folderViews$); + const folders = await firstValueFrom(this.folderViews$(userId)); if (!folders) { return encryptedFolders; } @@ -205,4 +227,63 @@ export class FolderService implements InternalFolderServiceAbstraction { ); return encryptedFolders; } + + /** + * Decrypts the folders for a user. + * @param userId the user id + * @param folderData encrypted folders + * @returns a list of decrypted folders + */ + private async decryptFolders( + userId: UserId, + folderData: Record, + ): Promise { + // Check if the decrypted folders are already cached + const decrypted = await firstValueFrom( + this.stateProvider.getUser(userId, FOLDER_DECRYPTED_FOLDERS).state$, + ); + if (decrypted?.length) { + return decrypted; + } + + if (folderData == null) { + return []; + } + + const folders = Object.values(folderData).map((f) => new Folder(f)); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (!userKey) { + return []; + } + + const decryptFolderPromises = folders.map((f) => + f.decryptWithKey(userKey, this.encryptService), + ); + const decryptedFolders = await Promise.all(decryptFolderPromises); + decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name")); + + const noneFolder = new FolderView(); + noneFolder.name = this.i18nService.t("noneFolder"); + decryptedFolders.push(noneFolder); + + // Cache the decrypted folders + await this.setDecryptedFolders(decryptedFolders, userId); + return decryptedFolders; + } + + /** + * @returns a SingleUserState for the encrypted folders. + */ + private encryptedFoldersState(userId: UserId) { + return this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS); + } + + /** + * Sets the decrypted folders state for a user. + * @param folders the decrypted folders + * @param userId the user id + */ + private async setDecryptedFolders(folders: FolderView[], userId: UserId): Promise { + await this.stateProvider.setUserState(FOLDER_DECRYPTED_FOLDERS, folders, userId); + } } diff --git a/libs/common/src/vault/services/key-state/folder.state.spec.ts b/libs/common/src/vault/services/key-state/folder.state.spec.ts index ece66b5d451..217a200ea88 100644 --- a/libs/common/src/vault/services/key-state/folder.state.spec.ts +++ b/libs/common/src/vault/services/key-state/folder.state.spec.ts @@ -1,11 +1,3 @@ -import { mock } from "jest-mock-extended"; - -import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; -import { FolderService } from "../../abstractions/folder/folder.service.abstraction"; -import { FolderData } from "../../models/data/folder.data"; -import { Folder } from "../../models/domain/folder"; -import { FolderView } from "../../models/view/folder.view"; - import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "./folder.state"; describe("encrypted folders", () => { @@ -31,48 +23,32 @@ describe("encrypted folders", () => { }); describe("derived decrypted folders", () => { - const keyService = mock(); - const folderService = mock(); const sut = FOLDER_DECRYPTED_FOLDERS; - let data: FolderData; - - beforeEach(() => { - data = { - id: "id", - name: "encName", - revisionDate: "2024-01-31T12:00:00.000Z", - }; - }); - afterEach(() => { - jest.resetAllMocks(); - }); - - it("should deserialize encrypted folders", async () => { - const inputObj = [data]; + it("should deserialize decrypted folders", async () => { + const inputObj = [ + { + id: "id", + name: "encName", + revisionDate: "2024-01-31T12:00:00.000Z", + }, + ]; - const expectedFolderView = { - id: "id", - name: "encName", - revisionDate: new Date("2024-01-31T12:00:00.000Z"), - }; + const expectedFolderView = [ + { + id: "id", + name: "encName", + revisionDate: new Date("2024-01-31T12:00:00.000Z"), + }, + ]; - const result = sut.deserialize(JSON.parse(JSON.stringify(inputObj))); + const result = sut.deserializer(JSON.parse(JSON.stringify(inputObj))); - expect(result).toEqual([expectedFolderView]); + expect(result).toEqual(expectedFolderView); }); - it("should derive encrypted folders", async () => { - const folderViewMock = new FolderView(new Folder(data)); - keyService.hasUserKey.mockResolvedValue(true); - folderService.decryptFolders.mockResolvedValue([folderViewMock]); - - const encryptedFoldersState = { id: data }; - const derivedStateResult = await sut.derive(encryptedFoldersState, { - folderService, - keyService, - }); - - expect(derivedStateResult).toEqual([folderViewMock]); + it("should handle null input", async () => { + const result = sut.deserializer(null); + expect(result).toEqual([]); }); }); diff --git a/libs/common/src/vault/services/key-state/folder.state.ts b/libs/common/src/vault/services/key-state/folder.state.ts index 7262d72d58e..99ad8e5ae35 100644 --- a/libs/common/src/vault/services/key-state/folder.state.ts +++ b/libs/common/src/vault/services/key-state/folder.state.ts @@ -1,34 +1,23 @@ import { Jsonify } from "type-fest"; -import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; -import { DeriveDefinition, FOLDER_DISK, UserKeyDefinition } from "../../../platform/state"; -import { FolderService } from "../../abstractions/folder/folder.service.abstraction"; +import { FOLDER_DISK, FOLDER_MEMORY, UserKeyDefinition } from "../../../platform/state"; import { FolderData } from "../../models/data/folder.data"; -import { Folder } from "../../models/domain/folder"; import { FolderView } from "../../models/view/folder.view"; export const FOLDER_ENCRYPTED_FOLDERS = UserKeyDefinition.record( FOLDER_DISK, - "folders", + "folder", { deserializer: (obj: Jsonify) => FolderData.fromJSON(obj), clearOn: ["logout"], }, ); -export const FOLDER_DECRYPTED_FOLDERS = DeriveDefinition.from< - Record, - FolderView[], - { folderService: FolderService; keyService: KeyService } ->(FOLDER_ENCRYPTED_FOLDERS, { - deserializer: (obj) => obj.map((f) => FolderView.fromJSON(f)), - derive: async (from, { folderService, keyService }) => { - const folders = Object.values(from || {}).map((f) => new Folder(f)); - - if (await keyService.hasUserKey()) { - return await folderService.decryptFolders(folders); - } else { - return []; - } +export const FOLDER_DECRYPTED_FOLDERS = new UserKeyDefinition( + FOLDER_MEMORY, + "decryptedFolders", + { + deserializer: (obj: Jsonify) => obj?.map((f) => FolderView.fromJSON(f)) ?? [], + clearOn: ["logout", "lock"], }, -}); +); diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 0035fbdf10d..f2bf7471d44 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -16,7 +16,7 @@ import { import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import * as JSZip from "jszip"; import { concat, Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs"; -import { filter, map, takeUntil } from "rxjs/operators"; +import { filter, map, switchMap, takeUntil } from "rxjs/operators"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -153,6 +153,8 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private _importBlockedByPolicy = false; protected isFromAC = false; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + formGroup = this.formBuilder.group({ vaultSelector: [ "myVault", @@ -206,6 +208,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { @Optional() protected importCollectionService: ImportCollectionServiceAbstraction, protected toastService: ToastService, + protected accountService: AccountService, ) {} protected get importBlockedByPolicy(): boolean { @@ -257,7 +260,10 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private handleImportInit() { // Filter out the no folder-item from folderViews$ - this.folders$ = this.folderService.folderViews$.pipe( + this.folders$ = this.activeUserId$.pipe( + switchMap((userId) => { + return this.folderService.folderViews$(userId); + }), map((folders) => folders.filter((f) => f.id != null)), ); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 9d58bcbf559..069df8606bf 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -178,8 +178,8 @@ describe("VaultExportService", () => { const activeAccount = { id: userId, ...accountInfo }; accountService.activeAccount$ = new BehaviorSubject(activeAccount); - folderService.getAllDecryptedFromState.mockResolvedValue(UserFolderViews); - folderService.getAllFromState.mockResolvedValue(UserFolders); + folderService.folderViews$.mockReturnValue(of(UserFolderViews)); + folderService.folders$.mockReturnValue(of(UserFolders)); kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG); encryptService.encrypt.mockResolvedValue(new EncString("encrypted")); @@ -295,7 +295,7 @@ describe("VaultExportService", () => { it("exported unencrypted object contains folders", async () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); - await folderService.getAllDecryptedFromState(); + folderService.folderViews$.mockReturnValue(of(UserFolderViews)); const actual = await exportService.getExport("json"); expectEqualFolderViews(UserFolderViews, actual); @@ -303,7 +303,7 @@ describe("VaultExportService", () => { it("exported encrypted json contains folders", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1)); - await folderService.getAllFromState(); + folderService.folders$.mockReturnValue(of(UserFolders)); const actual = await exportService.getExport("encrypted_json"); expectEqualFolders(UserFolders, actual); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index dbd2024d3ad..f9df9c7057f 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -32,6 +32,8 @@ export class IndividualVaultExportService extends BaseVaultExportService implements IndividualVaultExportServiceAbstraction { + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + constructor( private folderService: FolderService, private cipherService: CipherService, @@ -61,9 +63,10 @@ export class IndividualVaultExportService let decFolders: FolderView[] = []; let decCiphers: CipherView[] = []; const promises = []; + const activeUserId = await firstValueFrom(this.activeUserId$); promises.push( - this.folderService.getAllDecryptedFromState().then((folders) => { + firstValueFrom(this.folderService.folderViews$(activeUserId)).then((folders) => { decFolders = folders; }), ); @@ -87,9 +90,10 @@ export class IndividualVaultExportService let folders: Folder[] = []; let ciphers: Cipher[] = []; const promises = []; + const activeUserId = await firstValueFrom(this.activeUserId$); promises.push( - this.folderService.getAllFromState().then((f) => { + firstValueFrom(this.folderService.folders$(activeUserId)).then((f) => { folders = f; }), ); @@ -102,10 +106,9 @@ export class IndividualVaultExportService await Promise.all(promises); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), + const userKey = await this.keyService.getUserKeyWithLegacySupport( + await firstValueFrom(this.activeUserId$), ); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), userKey); const jsonDoc: BitwardenEncryptedIndividualJsonExport = { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts index 6bf05796070..7e04972f8ab 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -168,8 +168,8 @@ describe("VaultExportService", () => { kdfConfigService = mock(); - folderService.getAllDecryptedFromState.mockResolvedValue(UserFolderViews); - folderService.getAllFromState.mockResolvedValue(UserFolders); + folderService.folderViews$.mockReturnValue(of(UserFolderViews)); + folderService.folders$.mockReturnValue(of(UserFolders)); kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG); encryptService.encrypt.mockResolvedValue(new EncString("encrypted")); keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); @@ -294,7 +294,7 @@ describe("VaultExportService", () => { it("exported unencrypted object contains folders", async () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); - await folderService.getAllDecryptedFromState(); + const actual = await exportService.getExport("json"); expectEqualFolderViews(UserFolderViews, actual); @@ -302,7 +302,7 @@ describe("VaultExportService", () => { it("exported encrypted json contains folders", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1)); - await folderService.getAllFromState(); + const actual = await exportService.getExport("encrypted_json"); expectEqualFolders(UserFolders, actual); diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts index c2fbd024aa8..93a53345d3a 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts @@ -7,6 +7,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -31,12 +32,17 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService { private cipherService: CipherService = inject(CipherService); private folderService: FolderService = inject(FolderService); private collectionService: CollectionService = inject(CollectionService); + private accountService = inject(AccountService); + + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); async buildConfig( mode: CipherFormMode, cipherId?: CipherId, cipherType?: CipherType, ): Promise { + const activeUserId = await firstValueFrom(this.activeUserId$); + const [organizations, collections, allowPersonalOwnership, folders, cipher] = await firstValueFrom( combineLatest([ @@ -49,9 +55,9 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService { ), ), this.allowPersonalOwnership$, - this.folderService.folders$.pipe( + this.folderService.folders$(activeUserId).pipe( switchMap((f) => - this.folderService.folderViews$.pipe( + this.folderService.folderViews$(activeUserId).pipe( filter((d) => d.length - 1 === f.length), // -1 for "No Folder" in folderViews$ ), ), diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 57af96258fa..4bd87a7869d 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -1,11 +1,12 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnChanges, OnDestroy } from "@angular/core"; -import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; +import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { CollectionId } from "@bitwarden/common/types/guid"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -48,6 +49,8 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide export class CipherViewComponent implements OnChanges, OnDestroy { @Input({ required: true }) cipher: CipherView | null = null; + private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + /** * Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the * `CipherService` and the `collectionIds` property of the cipher. @@ -66,6 +69,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy { private organizationService: OrganizationService, private collectionService: CollectionService, private folderService: FolderService, + private accountService: AccountService, ) {} async ngOnChanges() { @@ -136,8 +140,14 @@ export class CipherViewComponent implements OnChanges, OnDestroy { } if (this.cipher.folderId) { + const activeUserId = await firstValueFrom(this.activeUserId$); + + if (!activeUserId) { + return; + } + this.folder$ = this.folderService - .getDecrypted$(this.cipher.folderId) + .getDecrypted$(this.cipher.folderId, activeUserId) .pipe(takeUntil(this.destroyed$)); } }