diff --git a/apps/web/src/app/vault/individual-vault/vault-next.component.html b/apps/web/src/app/vault/individual-vault/vault-next.component.html new file mode 100644 index 000000000000..c5a82dd5be34 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-next.component.html @@ -0,0 +1,25 @@ + + + + + + + diff --git a/apps/web/src/app/vault/individual-vault/vault-next.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-next.component.spec.ts new file mode 100644 index 000000000000..b62daf10385e --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-next.component.spec.ts @@ -0,0 +1,256 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of, Subject } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +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 { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { CipherRowMenuHandlers, CipherRowMenuService } from "@bitwarden/vault"; + +import { WebVaultItemActionsService } from "../services/vault-item-actions.service"; + +import { VaultNextComponent } from "./vault-next.component"; + +describe("VaultNextComponent", () => { + const userId = "user-1" as UserId; + + let fixture: ComponentFixture; + let itemActions: MockProxy; + let cipherRowMenuService: MockProxy; + let restrictedItemTypesService: MockProxy; + + let ciphers$: Subject; + let folders$: BehaviorSubject; + let collections$: BehaviorSubject; + let organizations$: BehaviorSubject; + + const buildCipher = (overrides: Partial = {}) => { + const cipher = new CipherView(); + cipher.id = "cipher-1"; + cipher.name = "Item"; + cipher.type = CipherType.Login; + cipher.edit = true; + cipher.favorite = false; + cipher.reprompt = CipherRepromptType.None; + return Object.assign(cipher, overrides); + }; + + const buildFolder = (id: string, name: string) => { + const folder = new FolderView(); + folder.id = id; + folder.name = name; + return folder; + }; + + /** + * The child components are stripped from the harness (see `overrideComponent` below) so this suite + * stays small, which means assertions read the signals the template binds rather than the + * rendered table. The bindings themselves are covered by the Angular template type-check. + */ + const component = () => fixture.componentInstance as any; + + /** The row menu handlers the component hands `CipherRowMenuService`. */ + const handlers = (): CipherRowMenuHandlers => { + component().rowActions(); + return cipherRowMenuService.getRowActions.mock.calls.at(-1)![1]; + }; + + beforeEach(async () => { + ciphers$ = new Subject(); + folders$ = new BehaviorSubject([]); + collections$ = new BehaviorSubject([]); + organizations$ = new BehaviorSubject([]); + + itemActions = mock(); + + cipherRowMenuService = mock(); + cipherRowMenuService.getRowActions.mockReturnValue([]); + + restrictedItemTypesService = mock(); + // `restricted$` is readonly on the service, so it can't be assigned onto the mock. + Object.defineProperty(restrictedItemTypesService, "restricted$", { value: of([]) }); + restrictedItemTypesService.isCipherRestricted.mockReturnValue(false); + + const accountService = mock(); + accountService.activeAccount$ = of({ id: userId } as Account); + + const cipherService = mock(); + cipherService.cipherListViews$.mockReturnValue(ciphers$ as never); + + const folderService = mock(); + folderService.folderViews$.mockReturnValue(folders$); + + const collectionService = mock(); + collectionService.decryptedCollections$.mockReturnValue(collections$); + + // Needed only by the projected toolbar button's i18n pipe. + const i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + + const organizationService = mock(); + organizationService.organizations$.mockReturnValue(organizations$); + + await TestBed.configureTestingModule({ + imports: [VaultNextComponent], + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: CipherRowMenuService, useValue: cipherRowMenuService }, + { provide: CipherService, useValue: cipherService }, + { provide: CollectionService, useValue: collectionService }, + { provide: FolderService, useValue: folderService }, + { provide: I18nService, useValue: i18nService }, + { provide: OrganizationService, useValue: organizationService }, + { provide: RestrictedItemTypesService, useValue: restrictedItemTypesService }, + ], + }) + .overrideComponent(VaultNextComponent, { + set: { + // The child components pull in their own dependency trees (the header needs a router, the + // table needs search and copy services), so NO_ERRORS_SCHEMA stands in for them. It has to + // be declared here rather than on the TestBed module — a standalone component resolves + // schemas from its own metadata. The i18n pipe stays, since a schema does not cover an + // unresolved pipe. + imports: [I18nPipe], + schemas: [NO_ERRORS_SCHEMA], + providers: [{ provide: WebVaultItemActionsService, useValue: itemActions }], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(VaultNextComponent); + fixture.detectChanges(); + }); + + describe("ciphers", () => { + it("is loading until the ciphers stream emits", () => { + expect(component().loading()).toBe(true); + + ciphers$.next([buildCipher()]); + fixture.detectChanges(); + + expect(component().loading()).toBe(false); + }); + + it("ignores the null emitted before the first decrypt", () => { + ciphers$.next(null); + fixture.detectChanges(); + + expect(component().loading()).toBe(true); + expect(component().ciphers()).toEqual([]); + }); + + it("excludes trashed, archived, and restricted items", () => { + const visible = buildCipher({ id: "visible" }); + const trashed = buildCipher({ id: "trashed", deletedDate: new Date() }); + const archived = buildCipher({ id: "archived", archivedDate: new Date() }); + const restricted = buildCipher({ id: "restricted" }); + + restrictedItemTypesService.isCipherRestricted.mockImplementation( + (cipher) => cipher.id === "restricted", + ); + + ciphers$.next([visible, trashed, archived, restricted]); + fixture.detectChanges(); + + expect( + component() + .ciphers() + .map((c: CipherView) => c.id), + ).toEqual(["visible"]); + }); + }); + + describe("filter option inputs", () => { + it("drops the empty-id pseudo-folder that folderViews$ appends", () => { + folders$.next([buildFolder("folder-1", "Work"), buildFolder("", "No folder")]); + fixture.detectChanges(); + + expect( + component() + .folders() + .map((f: FolderView) => f.id), + ).toEqual(["folder-1"]); + }); + + it("passes collections and organizations through to the table", () => { + const collection = { id: "collection-1" } as CollectionView; + const organization = { id: "org-1" } as Organization; + + collections$.next([collection]); + organizations$.next([organization]); + fixture.detectChanges(); + + expect(component().collections()).toEqual([collection]); + expect(component().organizations()).toEqual([organization]); + }); + }); + + describe("row actions", () => { + it("builds the menu from the shared service, scoped to the user's collections", () => { + const collection = { id: "collection-1" } as CollectionView; + const menu = [{ id: "edit" }] as any[]; + cipherRowMenuService.getRowActions.mockReturnValue(menu); + + collections$.next([collection]); + fixture.detectChanges(); + + expect(component().rowActions()).toBe(menu); + expect(cipherRowMenuService.getRowActions).toHaveBeenLastCalledWith( + [collection], + expect.anything(), + ); + }); + + it("routes edit and clone to the web dialogs", async () => { + const item = buildCipher(); + + await handlers().edit(item); + await handlers().clone(item); + + expect(itemActions.edit).toHaveBeenCalledWith(item); + expect(itemActions.clone).toHaveBeenCalledWith(item); + }); + + it("passes the user's collections to the assign handler", async () => { + const item = buildCipher(); + const collection = { id: "collection-1" } as CollectionView; + collections$.next([collection]); + fixture.detectChanges(); + + await handlers().assignToCollections(item); + + expect(itemActions.assignToCollections).toHaveBeenCalledWith(item, [collection]); + }); + }); + + describe("item activation", () => { + it("opens the read-only view when an item's name is activated", async () => { + const item = buildCipher(); + + await component().itemAction(item); + + expect(itemActions.view).toHaveBeenCalledWith(item); + expect(itemActions.edit).not.toHaveBeenCalled(); + }); + }); + + describe("toolbar", () => { + it("opens the add-item form", async () => { + await component().addItem(); + + expect(itemActions.add).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/vault-next.component.ts b/apps/web/src/app/vault/individual-vault/vault-next.component.ts new file mode 100644 index 000000000000..7ce2421d57b3 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-next.component.ts @@ -0,0 +1,143 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { combineLatest, map, shareReplay, switchMap } from "rxjs"; + +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 { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { ButtonModule } from "@bitwarden/components"; +import { I18nPipe, safeProvider } from "@bitwarden/ui-common"; +import { + CipherRowMenuHandlers, + CipherRowMenuService, + DefaultCipherFormConfigService, + VaultItemsTableComponent, + VaultItemsTableRowAction, + VaultOrganizationUserNotificationsComponent, +} from "@bitwarden/vault"; + +import { HeaderModule } from "../../layouts/header/header.module"; +import { WebVaultItemActionsService } from "../services/vault-item-actions.service"; + +/** + * The web individual vault built on the shared {@link VaultItemsTableComponent}, which owns its own + * search, filter chips, and sorting — so this page has no filter sidebar. + * + * Not yet wired: the typed filter adapter that syncs the + * table's chips to the URL, the redirect that rewrites legacy filter query params, and the + * `?itemId=&action=` deep link that opens an item on load. Until the chips are wired there is no + * route to trash or the archive from this page, so both are excluded from the list. + */ +@Component({ + selector: "app-vault-next", + templateUrl: "./vault-next.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tw-flex tw-flex-col tw-h-full tw-min-h-0", + }, + imports: [ + ButtonModule, + HeaderModule, + I18nPipe, + VaultItemsTableComponent, + VaultOrganizationUserNotificationsComponent, + ], + providers: [ + safeProvider({ provide: DefaultCipherFormConfigService, useAngularDecorators: true }), + safeProvider({ provide: WebVaultItemActionsService, useAngularDecorators: true }), + ], +}) +export class VaultNextComponent { + private readonly accountService = inject(AccountService); + private readonly cipherRowMenuService = inject(CipherRowMenuService); + private readonly cipherService = inject(CipherService); + private readonly collectionService = inject(CollectionService); + private readonly folderService = inject(FolderService); + private readonly itemActions = inject(WebVaultItemActionsService); + private readonly organizationService = inject(OrganizationService); + private readonly restrictedItemTypesService = inject(RestrictedItemTypesService); + + private readonly userId$ = this.accountService.activeAccount$.pipe(getUserId); + + private readonly ciphers$ = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + // Emits null until the first decrypt completes. + this.cipherService.cipherListViews$(userId).pipe(filterOutNullish()), + this.restrictedItemTypesService.restricted$, + ]), + ), + map(([ciphers, restricted]) => + ciphers.filter( + (cipher) => + !CipherViewLikeUtils.isDeleted(cipher) && + !CipherViewLikeUtils.isArchived(cipher) && + !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted), + ), + ), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + /** `undefined` until the ciphers stream first emits, which is what drives {@link loading}. */ + private readonly loadedCiphers = toSignal(this.ciphers$); + + protected readonly ciphers = computed(() => this.loadedCiphers() ?? []); + protected readonly loading = computed(() => this.loadedCiphers() === undefined); + + protected readonly folders = toSignal( + this.userId$.pipe( + switchMap((userId) => this.folderService.folderViews$(userId)), + // `folderViews$` appends a "no folder" pseudo-folder with an empty id. The table has its own + // NO_FOLDER sentinel for that option, so passing it through would duplicate it and defeat the + // table's own "user has no folders" check. + map((folders) => folders.filter((folder) => folder.id != null && folder.id !== "")), + ), + { initialValue: [] }, + ); + + protected readonly collections = toSignal( + this.userId$.pipe(switchMap((userId) => this.collectionService.decryptedCollections$(userId))), + { initialValue: [] }, + ); + + protected readonly organizations = toSignal( + this.userId$.pipe(switchMap((userId) => this.organizationService.organizations$(userId))), + { initialValue: [] }, + ); + + private readonly rowMenuHandlers = computed>(() => ({ + edit: (item) => this.itemActions.edit(item), + clone: (item) => this.itemActions.clone(item), + assignToCollections: (item) => this.itemActions.assignToCollections(item, this.collections()), + })); + + protected readonly rowActions = computed[]>(() => + this.cipherRowMenuService.getRowActions( + this.collections(), + this.rowMenuHandlers(), + ), + ); + + /** + * Clicking an item's name opens the read-only view, matching the legacy vault — the dialog offers + * its own Edit toggle from there, while the `edit` row action goes straight to the form. + * + * Bound as an input, so it must be a stable reference rather than a method: a new function on each + * change detection pass would churn the table's name column. + */ + protected readonly itemAction = (item: CipherViewLike): Promise => + this.itemActions.view(item); + + protected async addItem(): Promise { + await this.itemActions.add(); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault-routing.module.ts b/apps/web/src/app/vault/individual-vault/vault-routing.module.ts index bd61dab8b905..bba26266e6ac 100644 --- a/apps/web/src/app/vault/individual-vault/vault-routing.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault-routing.module.ts @@ -1,14 +1,24 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + +import { VaultNextComponent } from "./vault-next.component"; import { VaultComponent } from "./vault.component"; + const routes: Routes = [ - { - path: "", - component: VaultComponent, - data: { titleId: "vaults" }, - }, + ...featureFlaggedRoute({ + defaultComponent: VaultComponent, + flaggedComponent: VaultNextComponent, + featureFlag: FeatureFlag.VFO1Foundation, + routeOptions: { + path: "", + data: { titleId: "vaults" }, + }, + }), ]; + @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index 935cac2bafd2..4fe1a67678e5 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -10,6 +10,7 @@ import { SharedModule } from "../../shared"; import { BulkDeleteDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; import { PipesModule } from "./pipes/pipes.module"; +import { VaultNextComponent } from "./vault-next.component"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; @@ -25,6 +26,7 @@ import { VaultComponent } from "./vault.component"; BulkDeleteDialogsModule, CollectionDialogComponent, VaultComponent, + VaultNextComponent, ], }) export class VaultModule {} diff --git a/apps/web/src/app/vault/services/vault-item-actions.service.spec.ts b/apps/web/src/app/vault/services/vault-item-actions.service.spec.ts new file mode 100644 index 000000000000..09a0ffeb65fb --- /dev/null +++ b/apps/web/src/app/vault/services/vault-item-actions.service.spec.ts @@ -0,0 +1,269 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { + DefaultCipherFormConfigService, + PasswordRepromptService, + VaultItemDialogComponent, +} from "@bitwarden/vault"; + +import { AssignCollectionsWebComponent } from "../components/assign-collections"; + +import { WebVaultItemActionsService } from "./vault-item-actions.service"; + +describe("WebVaultItemActionsService", () => { + const userId = "user-1" as UserId; + const cipherId = "cipher-1" as CipherId; + + let service: WebVaultItemActionsService; + let cipherService: MockProxy; + let cipherFormConfigService: MockProxy; + let dialogService: MockProxy; + let passwordRepromptService: MockProxy; + let router: MockProxy; + let toastService: MockProxy; + + let itemDialogOpen: jest.SpyInstance; + let assignCollectionsDialogOpen: jest.SpyInstance; + + /** A plain personal login, no reprompt. */ + const buildCipher = (overrides: Partial = {}) => { + const cipher = new CipherView(); + cipher.id = cipherId; + cipher.name = "Item"; + cipher.type = CipherType.Login; + cipher.edit = true; + cipher.reprompt = CipherRepromptType.None; + return Object.assign(cipher, overrides); + }; + + beforeEach(() => { + cipherService = mock(); + cipherFormConfigService = mock(); + dialogService = mock(); + passwordRepromptService = mock(); + router = mock(); + toastService = mock(); + + // The stored cipher backs the dialog config; the row is what drives reprompt. + cipherService.get.mockResolvedValue({ + id: cipherId, + type: CipherType.Login, + edit: true, + } as unknown as Cipher); + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + router.navigate.mockResolvedValue(true); + + const accountService = mock(); + accountService.activeAccount$ = of({ id: userId } as Account); + + const i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + + itemDialogOpen = jest + .spyOn(VaultItemDialogComponent, "open") + .mockReturnValue({ closed: of(undefined) } as unknown as DialogRef); + assignCollectionsDialogOpen = jest + .spyOn(AssignCollectionsWebComponent, "open") + .mockReturnValue({ closed: of(undefined) } as unknown as DialogRef); + + TestBed.configureTestingModule({ + providers: [ + WebVaultItemActionsService, + { provide: AccountService, useValue: accountService }, + { provide: CipherService, useValue: cipherService }, + { provide: DefaultCipherFormConfigService, useValue: cipherFormConfigService }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: i18nService }, + { provide: PasswordRepromptService, useValue: passwordRepromptService }, + { provide: Router, useValue: router }, + { provide: ToastService, useValue: toastService }, + ], + }); + + service = TestBed.inject(WebVaultItemActionsService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("password reprompt", () => { + const protectedCipher = () => buildCipher({ reprompt: CipherRepromptType.Password }); + + beforeEach(() => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(false); + }); + + it("does not open the view dialog when the prompt is refused", async () => { + await service.view(protectedCipher()); + + expect(itemDialogOpen).not.toHaveBeenCalled(); + }); + + it("does not open the edit dialog when the prompt is refused", async () => { + await service.edit(protectedCipher()); + + expect(itemDialogOpen).not.toHaveBeenCalled(); + }); + + it("does not open the assign dialog when the prompt is refused", async () => { + await service.assignToCollections(protectedCipher(), []); + + expect(assignCollectionsDialogOpen).not.toHaveBeenCalled(); + }); + + it("still opens the dialog for an unprotected item", async () => { + await service.view(buildCipher()); + + expect(itemDialogOpen).toHaveBeenCalled(); + expect(passwordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + }); + }); + + describe("view", () => { + it("opens the dialog in view mode", async () => { + await service.view(buildCipher()); + + expect(itemDialogOpen).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ mode: "view" }), + ); + }); + + it("builds a partial-edit config when the user cannot edit the item", async () => { + cipherService.get.mockResolvedValue({ + id: cipherId, + type: CipherType.Login, + edit: false, + } as unknown as Cipher); + + await service.view(buildCipher()); + + expect(cipherFormConfigService.buildConfig).toHaveBeenCalledWith( + "partial-edit", + cipherId, + CipherType.Login, + ); + }); + + it("toasts and skips the dialog when the item no longer exists", async () => { + cipherService.get.mockResolvedValue(null as unknown as Cipher); + + await service.view(buildCipher()); + + expect(toastService.showToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: "error", message: "unknownCipher" }), + ); + expect(itemDialogOpen).not.toHaveBeenCalled(); + }); + + it("clears the item query params once the dialog closes", async () => { + await service.view(buildCipher()); + + expect(router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: { cipherId: null, itemId: null, action: null }, + replaceUrl: true, + }), + ); + }); + }); + + describe("edit and clone", () => { + it("opens the form in edit mode", async () => { + await service.edit(buildCipher()); + + expect(cipherFormConfigService.buildConfig).toHaveBeenCalledWith( + "edit", + cipherId, + CipherType.Login, + ); + expect(itemDialogOpen).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ mode: "form" }), + ); + }); + + it("opens the form in clone mode", async () => { + await service.clone(buildCipher()); + + expect(cipherFormConfigService.buildConfig).toHaveBeenCalledWith( + "clone", + cipherId, + CipherType.Login, + ); + }); + + it("does not clone when the passkey warning is declined", async () => { + dialogService.openSimpleDialog.mockResolvedValue(false); + const withPasskey = buildCipher(); + withPasskey.login.fido2Credentials = [{}] as never; + + await service.clone(withPasskey); + + expect(itemDialogOpen).not.toHaveBeenCalled(); + }); + }); + + describe("add", () => { + it("builds an add config with no seeded values", async () => { + await service.add(CipherType.Card); + + expect(cipherFormConfigService.buildConfig).toHaveBeenCalledWith( + "add", + undefined, + CipherType.Card, + ); + }); + }); + + describe("assignToCollections", () => { + const collection = (id: string, organizationId: string) => + ({ id, organizationId }) as CollectionView; + + it("offers only the owning organization's collections", async () => { + const orgCipher = buildCipher({ organizationId: "org-1" }); + const mine = collection("collection-1", "org-1"); + const theirs = collection("collection-2", "org-2"); + + await service.assignToCollections(orgCipher, [mine, theirs]); + + expect(assignCollectionsDialogOpen).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ + data: expect.objectContaining({ + organizationId: "org-1", + availableCollections: [mine], + }), + }), + ); + }); + + it("offers no collections for a personal item, leaving the destination to the dialog", async () => { + await service.assignToCollections(buildCipher(), [collection("collection-1", "org-1")]); + + expect(assignCollectionsDialogOpen).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ + data: expect.objectContaining({ + organizationId: undefined, + availableCollections: [], + }), + }), + ); + }); + }); +}); diff --git a/apps/web/src/app/vault/services/vault-item-actions.service.ts b/apps/web/src/app/vault/services/vault-item-actions.service.ts new file mode 100644 index 000000000000..9ce2fb631f24 --- /dev/null +++ b/apps/web/src/app/vault/services/vault-item-actions.service.ts @@ -0,0 +1,216 @@ +import { Injectable, inject } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom, lastValueFrom, map } from "rxjs"; + +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { + CipherFormConfig, + DefaultCipherFormConfigService, + PasswordRepromptService, + VaultItemDialogComponent, + VaultItemDialogMode, + VaultItemDialogResult, +} from "@bitwarden/vault"; + +import { AssignCollectionsWebComponent } from "../components/assign-collections"; + +/** + * The web individual vault's cipher actions that open a web-specific dialog. + */ +@Injectable() +export class WebVaultItemActionsService { + private readonly accountService = inject(AccountService); + private readonly cipherService = inject(CipherService); + private readonly cipherFormConfigService = inject(DefaultCipherFormConfigService); + private readonly dialogService = inject(DialogService); + private readonly i18nService = inject(I18nService); + private readonly passwordRepromptService = inject(PasswordRepromptService); + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + + private get userId$() { + return this.accountService.activeAccount$.pipe(getUserId); + } + + /** Opens the item in the combined view/edit dialog, starting in read-only view mode. */ + async view(cipher: CipherViewLike): Promise { + if (!(await this.reprompt([cipher]))) { + return; + } + + const stored = await this.getCipherOrToast(cipher); + if (stored == null) { + return; + } + + const formConfig = await this.cipherFormConfigService.buildConfig( + stored.edit ? "edit" : "partial-edit", + stored.id as CipherId, + stored.type, + ); + + await this.openItemDialog("view", formConfig); + } + + /** Opens the item in the combined view/edit dialog, starting in the edit form. */ + async edit(cipher: CipherViewLike): Promise { + await this.openForm(cipher, "edit"); + } + + /** + * Opens the add-item form. + * + * No `initialValues` are seeded — deriving a default organization, shared folder, or folder from + * the active filter arrives with the filter chip wiring. + */ + async add(cipherType?: CipherType): Promise { + const formConfig = await this.cipherFormConfigService.buildConfig("add", undefined, cipherType); + await this.openItemDialog("form", formConfig); + } + + /** + * Opens the clone form, warning first that passkeys are not carried over. + */ + async clone(cipher: CipherViewLike): Promise { + if (CipherViewLikeUtils.hasFido2Credentials(cipher)) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return; + } + } + + await this.openForm(cipher, "clone"); + } + + /** + * Opens the assign-to-shared-folders dialog for a single item. + * + * A personal item has no organization yet, so the dialog is opened with no target organization + * and no available shared folders; it lets the user pick the destination itself. + */ + async assignToCollections(cipher: CipherViewLike, collections: CollectionView[]): Promise { + if (!(await this.reprompt([cipher]))) { + return; + } + + const organizationId = uuidAsString(cipher.organizationId); + const availableCollections = + organizationId == null ? [] : collections.filter((c) => c.organizationId === organizationId); + + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { + data: { + ciphers: [await this.toCipherView(cipher)], + organizationId: organizationId as OrganizationId, + availableCollections, + activeCollection: undefined, + }, + }); + + await lastValueFrom(dialog.closed); + } + + private async openForm(cipher: CipherViewLike, mode: "edit" | "clone"): Promise { + if (!(await this.reprompt([cipher]))) { + return; + } + + const stored = await this.getCipherOrToast(cipher); + if (stored == null) { + return; + } + + const formConfig = await this.cipherFormConfigService.buildConfig( + mode, + stored.id as CipherId, + stored.type, + ); + + await this.openItemDialog("form", formConfig); + } + + private async openItemDialog( + mode: VaultItemDialogMode, + formConfig: CipherFormConfig, + ): Promise { + const dialogRef = VaultItemDialogComponent.open(this.dialogService, { mode, formConfig }); + const result = await lastValueFrom(dialogRef.closed); + + // The user is navigated to subscription settings elsewhere; leave the URL alone. + if (result === VaultItemDialogResult.PremiumUpgrade) { + return; + } + + await this.clearItemQueryParams(); + } + + /** + * Clears the item query params. `VaultItemDialogComponent` writes them itself when the user + * toggles between view and edit, so they outlive the dialog unless cleared here. + */ + private async clearItemQueryParams(): Promise { + await this.router.navigate([], { + queryParams: { cipherId: null, itemId: null, action: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + /** + * Reads the stored cipher so the dialog config is built from the full view, toasting and bailing + * if it has gone away since the row was rendered. + */ + private async getCipherOrToast(cipher: CipherViewLike) { + const userId = await firstValueFrom(this.userId$); + const stored = await this.cipherService.get(uuidAsString(cipher.id), userId); + + if (stored == null) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("unknownCipher"), + }); + await this.clearItemQueryParams(); + return undefined; + } + + return stored; + } + + /** `AssignCollectionsWebComponent` needs full `CipherView`s, which a list view is not. */ + private async toCipherView(cipher: CipherViewLike): Promise { + if (!CipherViewLikeUtils.isCipherListView(cipher)) { + return cipher; + } + + const userId = await firstValueFrom(this.userId$); + const cipherId = uuidAsString(cipher.id); + return firstValueFrom( + this.cipherService + .cipherViews$(userId) + .pipe(map((views) => views.find((v) => v.id === cipherId) as CipherView)), + ); + } + + private async reprompt(ciphers: CipherViewLike[]): Promise { + const anyProtected = ciphers.some((cipher) => cipher.reprompt !== CipherRepromptType.None); + + return !anyProtected || (await this.passwordRepromptService.showPasswordPrompt()); + } +} diff --git a/libs/vault/src/services/cipher-action.service.spec.ts b/libs/vault/src/services/cipher-action.service.spec.ts index 6f99f9dfb441..09cbe3f87f54 100644 --- a/libs/vault/src/services/cipher-action.service.spec.ts +++ b/libs/vault/src/services/cipher-action.service.spec.ts @@ -2,6 +2,8 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, BehaviorSubject, of } from "rxjs"; +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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -33,6 +35,7 @@ function makeCipher( favorite: boolean; isDeleted: boolean; isArchived: boolean; + organizationId: string; reprompt: CipherRepromptType; type: CipherType; }> = {}, @@ -44,6 +47,7 @@ function makeCipher( favorite: false, isDeleted: false, isArchived: false, + organizationId: undefined, reprompt: CipherRepromptType.None, ...overrides, } as unknown as CipherView; @@ -60,12 +64,14 @@ describe("CipherActionService", () => { let dialogService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; + let organizationService: MockProxy; let passwordRepromptService: MockProxy; let premiumUpgradePromptService: MockProxy; let toastService: MockProxy; let userCanArchiveSubject: BehaviorSubject; let userHasPremiumSubject: BehaviorSubject; + let organizationsSubject: BehaviorSubject; beforeEach(() => { archiveCipherUtilitiesService = mock(); @@ -75,6 +81,7 @@ describe("CipherActionService", () => { dialogService = mock(); i18nService = mock(); logService = mock(); + organizationService = mock(); passwordRepromptService = mock(); premiumUpgradePromptService = mock(); toastService = mock(); @@ -87,6 +94,9 @@ describe("CipherActionService", () => { userHasPremiumSubject.asObservable(), ); + organizationsSubject = new BehaviorSubject([]); + organizationService.organizations$.mockReturnValue(organizationsSubject.asObservable()); + i18nService.t.mockImplementation((key) => key); dialogService.openSimpleDialog.mockResolvedValue(true); passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); @@ -106,6 +116,7 @@ describe("CipherActionService", () => { { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: i18nService }, { provide: LogService, useValue: logService }, + { provide: OrganizationService, useValue: organizationService }, { provide: PasswordRepromptService, useValue: passwordRepromptService }, { provide: PremiumUpgradePromptService, useValue: premiumUpgradePromptService }, { provide: ToastService, useValue: toastService }, @@ -159,6 +170,14 @@ describe("CipherActionService", () => { expect(cipherService.restoreWithServer).not.toHaveBeenCalled(); }); + it("does nothing when password reprompt is cancelled", async () => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(false); + + await service.restore(makeCipher({ isDeleted: true, reprompt: CipherRepromptType.Password })); + + expect(cipherService.restoreWithServer).not.toHaveBeenCalled(); + }); + it("shows restoredItem toast for a normal deleted cipher", async () => { await service.restore(makeCipher({ isDeleted: true, isArchived: false })); @@ -294,6 +313,39 @@ describe("CipherActionService", () => { expect(openSpy).not.toHaveBeenCalled(); }); + it("does nothing when password reprompt is cancelled", async () => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(false); + const openSpy = jest.spyOn(AttachmentsV2Component, "open"); + + await service.viewAttachments(makeCipher({ reprompt: CipherRepromptType.Password })); + + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("prompts to upgrade the organization when it has no storage allocated", async () => { + organizationsSubject.next([{ id: "org-1", maxStorageGb: 0 } as Organization]); + const openSpy = jest.spyOn(AttachmentsV2Component, "open"); + + await service.viewAttachments(makeCipher({ organizationId: "org-1" })); + + expect(premiumUpgradePromptService.promptForPremium).toHaveBeenCalledWith("org-1"); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("opens for an organization item with storage, even without personal premium", async () => { + userHasPremiumSubject.next(false); + organizationsSubject.next([{ id: "org-1", maxStorageGb: 1 } as Organization]); + mockDialog(AttachmentDialogResult.Closed); + + await service.viewAttachments(makeCipher({ organizationId: "org-1" })); + + expect(premiumUpgradePromptService.promptForPremium).not.toHaveBeenCalled(); + expect(AttachmentsV2Component.open).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ organizationId: "org-1" }), + ); + }); + it("opens the attachments dialog with the cipher id and edit flag", async () => { const cipher = makeCipher({ id: "my-cipher", edit: true }); mockDialog(AttachmentDialogResult.Closed); @@ -404,6 +456,18 @@ describe("CipherActionService", () => { ); }); + it("titles the confirmation for what is about to happen", async () => { + await service.delete(makeCipher({ isDeleted: false })); + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ title: { key: "deleteItem" } }), + ); + + await service.delete(makeCipher({ isDeleted: true })); + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ title: { key: "permanentlyDeleteItem" } }), + ); + }); + it("logs error and still emits cipherModified$ when delete throws", async () => { cipherService.softDeleteWithServer.mockRejectedValue(new Error("server error")); const successPromise = firstValueFrom(service.cipherModified$); diff --git a/libs/vault/src/services/cipher-action.service.ts b/libs/vault/src/services/cipher-action.service.ts index 8ea800f8d994..0b5c41c20eee 100644 --- a/libs/vault/src/services/cipher-action.service.ts +++ b/libs/vault/src/services/cipher-action.service.ts @@ -3,12 +3,15 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { firstValueFrom, Subject, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; @@ -31,8 +34,8 @@ import { PasswordRepromptService } from "./password-reprompt.service"; * Handles cipher row actions that can be executed entirely within shared services * (no client-specific dialog components or component state required). * - * Actions that open desktop-specific drawers (view, edit, clone, share) - * remain as events emitted to the host component. + * Actions that open a client-specific view, form, or dialog (view, edit, clone, share) are left to + * the host. */ @Injectable({ providedIn: "root" }) export class CipherActionService { @@ -44,6 +47,7 @@ export class CipherActionService { private readonly dialogService = inject(DialogService); private readonly i18nService = inject(I18nService); private readonly logService = inject(LogService); + private readonly organizationService = inject(OrganizationService); private readonly passwordRepromptService = inject(PasswordRepromptService); private readonly premiumUpgradePromptService = inject(PremiumUpgradePromptService); private readonly toastService = inject(ToastService); @@ -71,6 +75,15 @@ export class CipherActionService { { initialValue: false }, ); + /** The organizations the active user belongs to, for resolving an item's owning organization. */ + private readonly organizations = toSignal( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.organizationService.organizations$(userId)), + ), + { initialValue: [] as Organization[] }, + ); + async toggleFavorite(cipher: CipherViewLike): Promise { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const fullCipher = await this.cipherService.getFullCipherView(cipher); @@ -93,6 +106,10 @@ export class CipherActionService { return; } + if (!(await this.promptPassword(cipher))) { + return; + } + const toastMessage = CipherViewLikeUtils.isArchived(cipher) ? this.i18nService.t("archivedItemRestored") : this.i18nService.t("restoredItem"); @@ -139,13 +156,21 @@ export class CipherActionService { } async viewAttachments(cipher: CipherViewLike): Promise { - if (!this.userHasPremium()) { - await this.premiumUpgradePromptService.promptForPremium(); + if (!(await this.promptPassword(cipher))) { + return; + } + + const organizationId = cipher.organizationId + ? (uuidAsString(cipher.organizationId) as OrganizationId) + : undefined; + + if (!(await this.canAccessAttachments(organizationId))) { return; } const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, + organizationId, canEditCipher: cipher.edit, }); @@ -166,7 +191,7 @@ export class CipherActionService { const isDeleted = CipherViewLikeUtils.isDeleted(cipher); const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "deleteItem" }, + title: { key: isDeleted ? "permanentlyDeleteItem" : "deleteItem" }, content: { key: isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" }, type: "warning", }); @@ -190,6 +215,32 @@ export class CipherActionService { this._cipherModified.next(); } + /** + * Whether the user is entitled to open the attachments dialog, prompting for the relevant upgrade + * when they are not. File storage is a premium feature for personal items, while an organization + * item needs storage allocated to its owning organization. + * + * An organization the user isn't a member of can't be checked for storage, so it is let through — + * the dialog itself is read-only in that case. + */ + private async canAccessAttachments(organizationId: OrganizationId | undefined): Promise { + if (organizationId == null) { + if (this.userHasPremium()) { + return true; + } + await this.premiumUpgradePromptService.promptForPremium(); + return false; + } + + const organization = this.organizations().find((o) => o.id === organizationId); + if (organization != null && !organization.maxStorageGb) { + await this.premiumUpgradePromptService.promptForPremium(organizationId); + return false; + } + + return true; + } + private async promptPassword(cipher: CipherViewLike): Promise { return ( cipher.reprompt === CipherRepromptType.None || diff --git a/libs/vault/src/services/cipher-row-menu.service.spec.ts b/libs/vault/src/services/cipher-row-menu.service.spec.ts index 6639c173ad6a..fb75de4d552b 100644 --- a/libs/vault/src/services/cipher-row-menu.service.spec.ts +++ b/libs/vault/src/services/cipher-row-menu.service.spec.ts @@ -2,7 +2,9 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +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 { mockAccountServiceWith } from "@bitwarden/common/spec"; @@ -53,13 +55,16 @@ function makeCipher( describe("CipherRowMenuService", () => { let service: CipherRowMenuService; let cipherArchiveService: MockProxy; + let organizationService: MockProxy; let restrictedItemTypesService: MockProxy; let i18nService: MockProxy; let userCanArchiveSubject: BehaviorSubject; let restrictedTypesSubject: BehaviorSubject; + let organizationsSubject: BehaviorSubject; beforeEach(() => { cipherArchiveService = mock(); + organizationService = mock(); restrictedItemTypesService = mock(); i18nService = mock(); i18nService.t.mockImplementation((key) => key); @@ -67,6 +72,9 @@ describe("CipherRowMenuService", () => { userCanArchiveSubject = new BehaviorSubject(false); restrictedTypesSubject = new BehaviorSubject([]); + organizationsSubject = new BehaviorSubject([]); + organizationService.organizations$.mockReturnValue(organizationsSubject.asObservable()); + cipherArchiveService.userCanArchive$.mockReturnValue(userCanArchiveSubject.asObservable()); Object.defineProperty(restrictedItemTypesService, "restricted$", { value: restrictedTypesSubject.asObservable(), @@ -76,6 +84,7 @@ describe("CipherRowMenuService", () => { providers: [ CipherRowMenuService, { provide: CipherArchiveService, useValue: cipherArchiveService }, + { provide: OrganizationService, useValue: organizationService }, { provide: RestrictedItemTypesService, useValue: restrictedItemTypesService }, { provide: I18nService, useValue: i18nService }, { provide: AccountService, useValue: mockAccountServiceWith(userId) }, @@ -246,33 +255,69 @@ describe("CipherRowMenuService", () => { }); describe("addToSharedFolder", () => { - it("shows when the cipher belongs to an org and can be assigned", () => { + /** A member of one organization holding one writable collection — the ordinary case. */ + function canAssignSomewhere() { + organizationsSubject.next([{ id: "org-1" } as Organization]); + return [{ id: "collection-1", readOnly: false } as CollectionView]; + } + + it("shows for an organization cipher that can be assigned", () => { + const collections = canAssignSomewhere(); + expect( show( "addToSharedFolder", makeCipher({ organizationId: "org-1", canAssignToCollections: true }), + collections, ), ).toBe(true); }); - it("hides when the cipher has no organization", () => { - expect(show("addToSharedFolder", makeCipher({ canAssignToCollections: true }))).toBe(false); + it("shows for a personal cipher, which is how one is moved into an organization", () => { + const collections = canAssignSomewhere(); + + expect( + show("addToSharedFolder", makeCipher({ canAssignToCollections: true }), collections), + ).toBe(true); + }); + + it("hides when the user belongs to no organization", () => { + const collections = [{ id: "collection-1", readOnly: false } as CollectionView]; + + expect( + show("addToSharedFolder", makeCipher({ canAssignToCollections: true }), collections), + ).toBe(false); + }); + + it("hides when the user has no collection they can write to", () => { + organizationsSubject.next([{ id: "org-1" } as Organization]); + const readOnly = [{ id: "collection-1", readOnly: true } as CollectionView]; + + expect( + show("addToSharedFolder", makeCipher({ canAssignToCollections: true }), readOnly), + ).toBe(false); }); it("hides when the user cannot assign to collections", () => { + const collections = canAssignSomewhere(); + expect( show( "addToSharedFolder", makeCipher({ organizationId: "org-1", canAssignToCollections: false }), + collections, ), ).toBe(false); }); it("hides when deleted", () => { + const collections = canAssignSomewhere(); + expect( show( "addToSharedFolder", makeCipher({ organizationId: "org-1", canAssignToCollections: true, isDeleted: true }), + collections, ), ).toBe(false); }); diff --git a/libs/vault/src/services/cipher-row-menu.service.ts b/libs/vault/src/services/cipher-row-menu.service.ts index 4a0962146392..f6aaa2e4705b 100644 --- a/libs/vault/src/services/cipher-row-menu.service.ts +++ b/libs/vault/src/services/cipher-row-menu.service.ts @@ -2,7 +2,9 @@ import { inject, Injectable } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { switchMap } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -32,6 +34,7 @@ export class CipherRowMenuService { private readonly i18nService = inject(I18nService); private readonly accountService = inject(AccountService); private readonly cipherArchiveService = inject(CipherArchiveService); + private readonly organizationService = inject(OrganizationService); private readonly restrictedItemTypesService = inject(RestrictedItemTypesService); private readonly cipherActionService = inject(CipherActionService); @@ -48,6 +51,18 @@ export class CipherRowMenuService { initialValue: [] as RestrictedCipherType[], }); + /** + * The organizations the active user belongs to — whether they have anywhere to move a personal + * item to. See {@link showAssignToCollections}. + */ + private readonly organizations = toSignal( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.organizationService.organizations$(userId)), + ), + { initialValue: [] as Organization[] }, + ); + /** Returns the full row action definitions for the cipher overflow menu. */ getRowActions( collections: CollectionView[] = [], @@ -94,7 +109,7 @@ export class CipherRowMenuService { label: this.i18nService.t("addToSharedFolder"), icon: "bwi-shared-folder", run: (item) => void handlers.assignToCollections(item), - show: (item) => this.showAssignToCollections(item), + show: (item) => this.showAssignToCollections(item, collections), }, { id: "archive", @@ -168,12 +183,20 @@ export class CipherRowMenuService { return this.canClone(cipher, collections) && !CipherViewLikeUtils.isDeleted(cipher); } - private showAssignToCollections(cipher: CipherViewLike): boolean { - return ( - !!cipher.organizationId && - CipherViewLikeUtils.canAssignToCollections(cipher) && - !CipherViewLikeUtils.isDeleted(cipher) - ); + /** + * Assignment covers personal items as well as organization ones — moving a personal item into an + * organization is what the dialog is for — so the gate is whether the user has somewhere to put + * it: an organization to move it into, and a collection they can write to. + */ + private showAssignToCollections(cipher: CipherViewLike, collections: CollectionView[]): boolean { + if ( + CipherViewLikeUtils.isDeleted(cipher) || + !CipherViewLikeUtils.canAssignToCollections(cipher) + ) { + return false; + } + + return this.organizations().length > 0 && collections.some((c) => !c.readOnly); } private showArchive(cipher: CipherViewLike): boolean {