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 {