From 8ad46ac6478fe7c0a984e2af56245dd18a095cf0 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Wed, 1 Apr 2026 13:25:17 -0500 Subject: [PATCH 01/39] when the footer is not present in the dialog remove empty div --- .../src/dialog/dialog/dialog.component.html | 21 +++++++++++-------- .../src/dialog/dialog/dialog.component.ts | 6 +++++- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index cb95186b3bd8..8c1f997df8a7 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -69,6 +69,7 @@ cdkScrollable [ngClass]="{ 'tw-py-2 tw-ps-6 tw-pe-6': !disablePadding(), + 'tw-pb-6': !hasFooter() && !disablePadding(), 'tw-overflow-y-auto': !loading(), 'tw-invisible tw-overflow-y-hidden': loading(), 'tw-py-4': background() === 'alt', @@ -89,13 +90,15 @@ }" data-chromatic="ignore" > - + @if (hasFooter()) { + + } diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 86ba1d24ad9d..cab8acc930f4 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -3,6 +3,7 @@ import { CdkScrollable } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { Component, + contentChild, effect, inject, viewChild, @@ -31,6 +32,7 @@ import { DialogRef } from "../dialog.service"; import { DialogCloseDirective } from "../directives/dialog-close.directive"; import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive"; import { DrawerService } from "../drawer.service"; +import { DialogFooterDirective } from "../simple-dialog/simple-dialog.component"; type DialogSize = "small" | "default" | "large"; @@ -101,7 +103,6 @@ export class DialogComponent implements AfterViewInit { protected dialogRef = inject(DialogRef, { optional: true }); protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody); - private scrollableBody$ = toObservable(this.scrollableBody); private scrollBottom$ = toObservable(this.scrollBottom); @@ -111,6 +112,9 @@ export class DialogComponent implements AfterViewInit { ), ); + private readonly footerDirective = contentChild(DialogFooterDirective); + protected readonly hasFooter = computed(() => !!this.footerDirective()); + /** Background color */ readonly background = input<"default" | "alt">("default"); From 5c59fe89354a0499d15aa33bfdf901f011cd7302 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Wed, 1 Apr 2026 13:52:47 -0500 Subject: [PATCH 02/39] add new item type feature flag --- libs/common/src/enums/feature-flag.enum.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index bfb0773de3dd..f92bbc50014c 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -66,6 +66,7 @@ export enum FeatureFlag { AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture", /* Vault */ + PM32009NewItemTypes = "pm-32009-new-item-types", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", @@ -128,6 +129,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE, /* Vault */ + [FeatureFlag.PM32009NewItemTypes]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, From 7313d076250eb0dcf8bea05c7497c12a8aa013c1 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Wed, 1 Apr 2026 14:05:06 -0500 Subject: [PATCH 03/39] add subtitle text to cipher menu items --- apps/browser/src/_locales/en/messages.json | 21 +++++++++++ apps/desktop/src/locales/en/messages.json | 21 +++++++++++ apps/web/src/locales/en/messages.json | 29 ++++++++++++++- .../src/vault/types/cipher-menu-items.ts | 37 ++++++++++++++++--- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0705157b4421..bfc4c2d338bd 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2102,6 +2102,27 @@ "typeNote": { "message": "Note" }, + "typeLoginSubtitle": { + "message": "Website or app" + }, + "typeCardSubtitle": { + "message": "Credit or debit card" + }, + "typeIdentitySubtitle": { + "message": "Personal info" + }, + "typeNoteSubtitle": { + "message": "Important text" + }, + "typeSshKeySubtitle": { + "message": "Server login token" + }, + "folderSubtitle": { + "message": "Organize your items" + }, + "collectionSubtitle": { + "message": "Organize shared items" + }, "newItemHeaderLogin": { "message": "New Login", "description": "Header for new login item type" diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8bcb8097a4e8..cd139e578c9b 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -35,6 +35,27 @@ "typeSshKey": { "message": "SSH key" }, + "typeLoginSubtitle": { + "message": "Website or app" + }, + "typeCardSubtitle": { + "message": "Credit or debit card" + }, + "typeIdentitySubtitle": { + "message": "Personal info" + }, + "typeNoteSubtitle": { + "message": "Important text" + }, + "typeSshKeySubtitle": { + "message": "Server login token" + }, + "folderSubtitle": { + "message": "Organize your items" + }, + "collectionSubtitle": { + "message": "Organize shared items" + }, "folders": { "message": "Folders" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 543e0f1f4caf..0f18136e3f74 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -985,6 +985,27 @@ "typeSshKey": { "message": "SSH key" }, + "typeLoginSubtitle": { + "message": "Website or app" + }, + "typeCardSubtitle": { + "message": "Credit or debit card" + }, + "typeIdentitySubtitle": { + "message": "Personal info" + }, + "typeNoteSubtitle": { + "message": "Important text" + }, + "typeSshKeySubtitle": { + "message": "Server login token" + }, + "folderSubtitle": { + "message": "Organize your items" + }, + "collectionSubtitle": { + "message": "Organize shared items" + }, "typeLoginPlural": { "message": "Logins" }, @@ -1054,6 +1075,12 @@ "addItem": { "message": "Add item" }, + "chooseItemToAdd": { + "message": "Choose item to add" + }, + "chooseItemToAddDesc": { + "message": "Select the item type you want to add to your vault." + }, "editItem": { "message": "Edit item" }, @@ -13179,4 +13206,4 @@ } } } -} \ No newline at end of file +} diff --git a/libs/common/src/vault/types/cipher-menu-items.ts b/libs/common/src/vault/types/cipher-menu-items.ts index 7108d0d0bd68..8c2759da45af 100644 --- a/libs/common/src/vault/types/cipher-menu-items.ts +++ b/libs/common/src/vault/types/cipher-menu-items.ts @@ -10,15 +10,42 @@ export type CipherMenuItem = { icon: string; /** The i18n key for the label text */ labelKey: string; + /** The i18n key for the subtitle text */ + subtitleKey: string; }; /** * All available cipher menu items with their associated icons and labels */ export const CIPHER_MENU_ITEMS = Object.freeze([ - { type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" }, - { type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" }, - { type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" }, - { type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "typeNote" }, - { type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" }, + { + type: CipherType.Login, + icon: "bwi-globe", + labelKey: "typeLogin", + subtitleKey: "typeLoginSubtitle", + }, + { + type: CipherType.Card, + icon: "bwi-credit-card", + labelKey: "typeCard", + subtitleKey: "typeCardSubtitle", + }, + { + type: CipherType.Identity, + icon: "bwi-id-card", + labelKey: "typeIdentity", + subtitleKey: "typeIdentitySubtitle", + }, + { + type: CipherType.SecureNote, + icon: "bwi-sticky-note", + labelKey: "typeNote", + subtitleKey: "typeNoteSubtitle", + }, + { + type: CipherType.SshKey, + icon: "bwi-key", + labelKey: "typeSshKey", + subtitleKey: "typeSshKeySubtitle", + }, ] as const) satisfies readonly CipherMenuItem[]; From 75f1c89c0024f145b0d3be09c3f13aa81a508347 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Wed, 1 Apr 2026 15:26:22 -0500 Subject: [PATCH 04/39] add add item dialog and associated components --- .../add-item-dialog.component.html | 18 ++ .../add-item-dialog.component.spec.ts | 88 +++++++++ .../add-item-dialog.component.ts | 61 ++++++ .../add-item-grid.component.html | 22 +++ .../add-item-grid.component.spec.ts | 175 ++++++++++++++++++ .../add-item-grid/add-item-grid.component.ts | 77 ++++++++ .../new-cipher-menu.component.html | 22 ++- .../new-cipher-menu.component.ts | 19 +- libs/vault/src/index.ts | 2 + 9 files changed, 479 insertions(+), 5 deletions(-) create mode 100644 libs/vault/src/components/add-item-dialog/add-item-dialog.component.html create mode 100644 libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts create mode 100644 libs/vault/src/components/add-item-dialog/add-item-dialog.component.ts create mode 100644 libs/vault/src/components/add-item-grid/add-item-grid.component.html create mode 100644 libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts create mode 100644 libs/vault/src/components/add-item-grid/add-item-grid.component.ts diff --git a/libs/vault/src/components/add-item-dialog/add-item-dialog.component.html b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.html new file mode 100644 index 000000000000..e47d690952c6 --- /dev/null +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.html @@ -0,0 +1,18 @@ + +
+ {{ "chooseItemToAdd" | i18n }} + + {{ "chooseItemToAddDesc" | i18n }} + +
+
+ +
+
diff --git a/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts new file mode 100644 index 000000000000..131fee44d6a1 --- /dev/null +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts @@ -0,0 +1,88 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; + +import { + AddItemDialogComponent, + AddItemDialogData, + AddItemDialogResult, +} from "./add-item-dialog.component"; + +describe("AddItemDialogComponent", () => { + let component: AddItemDialogComponent; + let fixture: ComponentFixture; + let dialogData: AddItemDialogData; + + const close = jest.fn(); + const dialogRef = { close }; + const restricted$ = new BehaviorSubject<[]>([]); + + beforeEach(async () => { + close.mockClear(); + restricted$.next([]); + + await TestBed.configureTestingModule({ + imports: [AddItemDialogComponent, NoopAnimationsModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogRef, useValue: dialogRef }, + { provide: DIALOG_DATA, useFactory: () => dialogData }, + { + provide: RestrictedItemTypesService, + useValue: { restricted$ }, + }, + ], + }).compileComponents(); + }); + + function createComponent(data: AddItemDialogData) { + dialogData = data; + fixture = TestBed.createComponent(AddItemDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it("closes with cipher result when onCipherSelected is called", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: true, + }); + + component["onCipherSelected"](CipherType.Login); + + expect(close).toHaveBeenCalledWith({ + result: AddItemDialogResult.Cipher, + cipherType: CipherType.Login, + }); + }); + + it("closes with folder result when onFolderSelected is called", () => { + createComponent({ + canCreateFolder: true, + canCreateCollection: false, + canCreateSshKey: false, + }); + + component["onFolderSelected"](); + + expect(close).toHaveBeenCalledWith({ result: AddItemDialogResult.Folder }); + }); + + it("closes with collection result when onCollectionSelected is called", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: true, + canCreateSshKey: false, + }); + + component["onCollectionSelected"](); + + expect(close).toHaveBeenCalledWith({ result: AddItemDialogResult.Collection }); + }); +}); diff --git a/libs/vault/src/components/add-item-dialog/add-item-dialog.component.ts b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.ts new file mode 100644 index 000000000000..421f011a9e32 --- /dev/null +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, Inject } from "@angular/core"; + +import { CipherType } from "@bitwarden/common/vault/enums"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { DIALOG_DATA, DialogModule, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AddItemGridComponent } from "../add-item-grid/add-item-grid.component"; + +export const AddItemDialogResult = Object.freeze({ + Cipher: "cipher", + Folder: "folder", + Collection: "collection", +} as const); + +export type AddItemDialogResult = UnionOfValues; + +export type AddItemDialogCloseResult = + | { result: typeof AddItemDialogResult.Cipher; cipherType: CipherType } + | { result: typeof AddItemDialogResult.Folder } + | { result: typeof AddItemDialogResult.Collection }; + +export type AddItemDialogData = { + canCreateFolder: boolean; + canCreateCollection: boolean; + canCreateSshKey: boolean; +}; + +@Component({ + selector: "vault-add-item-dialog", + templateUrl: "./add-item-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DialogModule, I18nPipe, AddItemGridComponent], +}) +export class AddItemDialogComponent { + constructor( + private readonly dialogRef: DialogRef, + @Inject(DIALOG_DATA) readonly data: AddItemDialogData, + ) { } + + protected onCipherSelected(cipherType: CipherType): void { + this.dialogRef.close({ result: AddItemDialogResult.Cipher, cipherType }); + } + + protected onFolderSelected(): void { + this.dialogRef.close({ result: AddItemDialogResult.Folder }); + } + + protected onCollectionSelected(): void { + this.dialogRef.close({ result: AddItemDialogResult.Collection }); + } + + static open( + dialogService: DialogService, + data: AddItemDialogData, + ): DialogRef { + return dialogService.open(AddItemDialogComponent, { + data, + }); + } +} diff --git a/libs/vault/src/components/add-item-grid/add-item-grid.component.html b/libs/vault/src/components/add-item-grid/add-item-grid.component.html new file mode 100644 index 000000000000..17015ff986f9 --- /dev/null +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.html @@ -0,0 +1,22 @@ +
    + @for (item of items(); track item.labelKey) { +
  • + +
  • + } +
diff --git a/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts b/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts new file mode 100644 index 000000000000..2f2fdde672ae --- /dev/null +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts @@ -0,0 +1,175 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; + +import { AddItemGridComponent } from "./add-item-grid.component"; + +describe("AddItemComponent", () => { + let component: AddItemGridComponent; + let fixture: ComponentFixture; + + const restricted$ = new BehaviorSubject([]); + + beforeEach(async () => { + restricted$.next([]); + + await TestBed.configureTestingModule({ + imports: [AddItemGridComponent, NoopAnimationsModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { + provide: RestrictedItemTypesService, + useValue: { restricted$ }, + }, + ], + }).compileComponents(); + }); + + function createComponent(inputs: { + canCreateFolder: boolean; + canCreateCollection: boolean; + canCreateSshKey: boolean; + }) { + fixture = TestBed.createComponent(AddItemGridComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("canCreateFolder", inputs.canCreateFolder); + fixture.componentRef.setInput("canCreateCollection", inputs.canCreateCollection); + fixture.componentRef.setInput("canCreateSshKey", inputs.canCreateSshKey); + fixture.detectChanges(); + } + + it("renders all 5 cipher types when unrestricted and canCreateSshKey=true", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: true, + }); + + const items = component["items"](); + expect(items.length).toBe(5); + expect(items.map((i) => i.labelKey)).toEqual( + expect.arrayContaining(["typeLogin", "typeCard", "typeIdentity", "typeNote", "typeSshKey"]), + ); + }); + + it("hides SSH key when canCreateSshKey=false", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: false, + }); + + const items = component["items"](); + expect(items.map((i) => i.labelKey)).not.toContain("typeSshKey"); + }); + + it("shows folder when canCreateFolder=true", () => { + createComponent({ + canCreateFolder: true, + canCreateCollection: false, + canCreateSshKey: false, + }); + + const items = component["items"](); + expect(items.map((i) => i.labelKey)).toContain("folder"); + }); + + it("hides folder when canCreateFolder=false", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: false, + }); + + const items = component["items"](); + expect(items.map((i) => i.labelKey)).not.toContain("folder"); + }); + + it("shows collection when canCreateCollection=true", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: true, + canCreateSshKey: false, + }); + + const items = component["items"](); + expect(items.map((i) => i.labelKey)).toContain("collection"); + }); + + it("hides collection when canCreateCollection=false", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: false, + }); + + const items = component["items"](); + expect(items.map((i) => i.labelKey)).not.toContain("collection"); + }); + + it("excludes restricted cipher types", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: true, + }); + + restricted$.next([{ cipherType: CipherType.Login, allowViewOrgIds: [] } as any]); + fixture.detectChanges(); + + const items = component["items"](); + expect(items.map((i) => i.labelKey)).not.toContain("typeLogin"); + }); + + it("emits cipherSelected when a cipher type is selected", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: true, + }); + + const cipherSelected = jest.fn(); + component.cipherSelected.subscribe(cipherSelected); + + const loginItem = component["items"]().find((i) => i.labelKey === "typeLogin"); + loginItem!.action(); + + expect(cipherSelected).toHaveBeenCalledWith(CipherType.Login); + }); + + it("emits folderSelected when folder is selected", () => { + createComponent({ + canCreateFolder: true, + canCreateCollection: false, + canCreateSshKey: false, + }); + + const folderSelected = jest.fn(); + component.folderSelected.subscribe(folderSelected); + + const folderItem = component["items"]().find((i) => i.labelKey === "folder"); + folderItem!.action(); + + expect(folderSelected).toHaveBeenCalled(); + }); + + it("emits collectionSelected when collection is selected", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: true, + canCreateSshKey: false, + }); + + const collectionSelected = jest.fn(); + component.collectionSelected.subscribe(collectionSelected); + + const collectionItem = component["items"]().find((i) => i.labelKey === "collection"); + collectionItem!.action(); + + expect(collectionSelected).toHaveBeenCalled(); + }); +}); diff --git a/libs/vault/src/components/add-item-grid/add-item-grid.component.ts b/libs/vault/src/components/add-item-grid/add-item-grid.component.ts new file mode 100644 index 000000000000..0782cfde29ed --- /dev/null +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.ts @@ -0,0 +1,77 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, input, output } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { + RestrictedCipherType, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +import { BitwardenIcon, IconComponent, ItemModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +type DialogItem = { + icon: BitwardenIcon; + labelKey: string; + subtitleKey: string; + action: () => void; +}; + +@Component({ + selector: "vault-add-item-grid", + templateUrl: "./add-item-grid.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, JslibModule, I18nPipe, IconComponent, ItemModule], +}) +export class AddItemGridComponent { + readonly canCreateFolder = input(false); + readonly canCreateCollection = input(false); + readonly canCreateSshKey = input(false); + + readonly cipherSelected = output(); + readonly folderSelected = output(); + readonly collectionSelected = output(); + + private readonly restrictedTypes = toSignal(this.restrictedItemTypesService.restricted$, { + initialValue: [] as RestrictedCipherType[], + }); + + protected readonly items = computed(() => { + const restrictedTypes = this.restrictedTypes(); + const items: DialogItem[] = CIPHER_MENU_ITEMS.filter((item) => { + if (!this.canCreateSshKey() && item.type === CipherType.SshKey) { + return false; + } + return !restrictedTypes.some((r) => r.cipherType === item.type); + }).map((item) => ({ + icon: item.icon, + labelKey: item.labelKey, + subtitleKey: item.subtitleKey, + action: () => this.cipherSelected.emit(item.type), + })); + + if (this.canCreateFolder()) { + items.push({ + icon: "bwi-folder", + labelKey: "folder", + subtitleKey: "folderSubtitle", + action: () => this.folderSelected.emit(), + }); + } + + if (this.canCreateCollection()) { + items.push({ + icon: "bwi-collection-shared", + labelKey: "collection", + subtitleKey: "collectionSubtitle", + action: () => this.collectionSelected.emit(), + }); + } + + return items; + }); + + constructor(private readonly restrictedItemTypesService: RestrictedItemTypesService) {} +} diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 5b70b4326568..64f1b111ae17 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -1,10 +1,26 @@ - +@if (useNewItemDialog() && (canCreateCipher() || canCreateCollection() || canCreateFolder())) { + +} @else if (canCreateCipher() || canCreateCollection() || canCreateFolder()) {
-
+} diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts index 76204a131aaf..ea0c122824ec 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts @@ -1,9 +1,11 @@ import { CommonModule } from "@angular/common"; import { Component, input, output } from "@angular/core"; -import { toObservable } from "@angular/core/rxjs-interop"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { combineLatest, map, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; @@ -47,8 +49,17 @@ export class NewCipherMenuComponent { folderAdded = output(); collectionAdded = output(); cipherAdded = output(); + onAddItemDialog = output(); - constructor(private restrictedItemTypesService: RestrictedItemTypesService) {} + protected readonly useNewItemDialog = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM32009NewItemTypes), + { initialValue: false }, + ); + + constructor( + private restrictedItemTypesService: RestrictedItemTypesService, + private configService: ConfigService, + ) { } /** * Returns an observable that emits the cipher menu items, filtered by the restricted types. @@ -106,6 +117,10 @@ export class NewCipherMenuComponent { protected handleButtonClick(): void { if (this.isOnlyCollectionCreation()) { this.collectionAdded.emit(); + return; + } + if (this.useNewItemDialog()) { + this.onAddItemDialog.emit(); } } } diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 59e354f80fa3..8e4335574e38 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -27,6 +27,8 @@ export { PasswordHistoryViewComponent } from "./components/password-history-view export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component"; export { openPasswordHistoryDialog } from "./components/password-history/password-history.component"; export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component"; +export * from "./components/add-item-grid/add-item-grid.component"; +export * from "./components/add-item-dialog/add-item-dialog.component"; export * from "./components/carousel"; export * from "./components/new-cipher-menu/new-cipher-menu.component"; export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component"; From c6f2e9687864aad12955399732cee935dbf6009d Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Wed, 1 Apr 2026 15:41:25 -0500 Subject: [PATCH 05/39] integrate the new item dialog into web and AC vaults --- .../vault-header/vault-header.component.html | 1 + .../vault-header/vault-header.component.ts | 11 ++++++-- .../collections/vault.component.html | 1 + .../collections/vault.component.ts | 26 +++++++++++++++++++ .../vault-header/vault-header.component.html | 1 + .../vault-header/vault-header.component.ts | 10 ++++++- .../individual-vault/vault.component.html | 1 + .../individual-vault/vault.component.spec.ts | 8 +++++- .../vault/individual-vault/vault.component.ts | 25 ++++++++++++++++++ 9 files changed, 80 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html index 9c3e607d6eb6..d38d2a78a9f5 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html @@ -114,6 +114,7 @@ [canCreateCollection]="canCreateCollection" (cipherAdded)="addCipher($event)" (collectionAdded)="addCollection()" + (onAddItemDialog)="openAddItemDialog()" /> diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts index 1c9ae89f36bd..fdaca3ef5741 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts @@ -3,7 +3,7 @@ // FIXME: rename output bindings and then remove this line /* eslint-disable @angular-eslint/no-output-on-prefix */ import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, Input, output, Output } from "@angular/core"; import { Router } from "@angular/router"; import { firstValueFrom, switchMap } from "rxjs"; @@ -109,6 +109,9 @@ export class VaultHeaderComponent { // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() searchTextChanged = new EventEmitter(); + /** Emits an event when the add item dialog should be opened */ + onOpenAddItemDialog = output(); + protected CollectionDialogTabType = CollectionDialogTabType; /** The cipher type enum. */ @@ -120,7 +123,7 @@ export class VaultHeaderComponent { private collectionAdminService: CollectionAdminService, private router: Router, private accountService: AccountService, - ) {} + ) { } get title() { const headerType = this.i18nService.t("collections").toLowerCase(); @@ -218,6 +221,10 @@ export class VaultHeaderComponent { this.onAddCipher.emit(cipherType); } + openAddItemDialog(): void { + this.onOpenAddItemDialog.emit(); + } + async addCollection() { if (this.organization.productTierType === ProductTierType.Free) { const collections = await firstValueFrom( diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index 9ea0d8734872..85314c25d69a 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -27,6 +27,7 @@ (onEditCollection)="editCollection(selectedCollection?.node, $event.tab, $event.readonly)" (onDeleteCollection)="deleteCollection(selectedCollection?.node)" (searchTextChanged)="filterSearchText($event)" + (onOpenAddItemDialog)="openAddItemDialog()" > } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 60b5b2902849..46247709632f 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -76,6 +76,9 @@ import { ToastService, } from "@bitwarden/components"; import { + AddItemDialogCloseResult, + AddItemDialogComponent, + AddItemDialogResult, AttachmentDialogResult, AttachmentsV2Component, CipherFormConfig, @@ -837,6 +840,27 @@ export class VaultComponent implements OnInit, OnDestroy { } } + /** + * Opens the add-item type selection dialog and handles the result. + */ + protected async openAddItemDialog(): Promise { + const organization = await firstValueFrom(this.organization$); + const ref = AddItemDialogComponent.open(this.dialogService, { + canCreateFolder: false, + canCreateCollection: organization?.canCreateNewCollections ?? false, + canCreateSshKey: true, + }); + const result: AddItemDialogCloseResult | undefined = await firstValueFrom(ref.closed); + if (!result) { + return; + } + if (result.result === AddItemDialogResult.Cipher) { + await this.addCipher(result.cipherType); + } else if (result.result === AddItemDialogResult.Collection) { + await this.addCollection(); + } + } + /** Opens the Add/Edit Dialog */ async addCipher(cipherType?: CipherType) { const cipherFormConfig = await this.cipherFormConfigService.buildConfig( @@ -921,6 +945,7 @@ export class VaultComponent implements OnInit, OnDestroy { formConfig: CipherFormConfig, cipher?: CipherView, activeCollectionId?: CollectionId, + showBackButton?: boolean, ) { this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { mode, @@ -928,6 +953,7 @@ export class VaultComponent implements OnInit, OnDestroy { activeCollectionId, isAdminConsoleAction: true, restore: this.restore, + showBackButton, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index 0bd03f56ea23..66f7d5516606 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -86,6 +86,7 @@ (cipherAdded)="addCipher($event)" (folderAdded)="addFolder()" (collectionAdded)="addCollection()" + (onAddItemDialog)="openAddItemDialog()" /> diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index c3d6d4bcff31..1003b85cab35 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -7,6 +7,7 @@ import { inject, Input, Output, + output, } from "@angular/core"; import { Router } from "@angular/router"; import { firstValueFrom, switchMap } from "rxjs"; @@ -122,6 +123,9 @@ export class VaultHeaderComponent { // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDeleteCollection = new EventEmitter(); + /** Emits an event when the add item dialog should be opened */ + readonly onOpenAddItemDialog = output(); + constructor( private readonly i18nService: I18nService, private readonly collectionAdminService: CollectionAdminService, @@ -129,7 +133,7 @@ export class VaultHeaderComponent { private readonly router: Router, private readonly configService: ConfigService, private readonly accountService: AccountService, - ) {} + ) { } /** * The id of the organization that is currently being filtered on. @@ -257,6 +261,10 @@ export class VaultHeaderComponent { this.onAddCipher.emit(cipherType); } + protected openAddItemDialog(): void { + this.onOpenAddItemDialog.emit(); + } + async addFolder(): Promise { this.onAddFolder.emit(); } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 56f0bcc1db7e..c16be6cead85 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -11,6 +11,7 @@ (onAddFolder)="addFolder()" (onEditCollection)="editCollection(selectedCollection.node, $event.tab)" (onDeleteCollection)="deleteCollection(selectedCollection.node)" + (onOpenAddItemDialog)="openAddItemDialog()" > { { provide: OrganizationWarningsService, useValue: mock() }, { provide: PremiumUpgradePromptService, useValue: mock() }, { provide: SyncService, useValue: mock() }, - { provide: ConfigService, useValue: mock() }, + { + provide: ConfigService, + useValue: { + ...mock(), + getFeatureFlag$: jest.fn().mockReturnValue(of(false)), + }, + }, { provide: DialogService, useValue: mock() }, { provide: WelcomeDialogService, useValue: mock() }, { provide: OrganizationUserApiService, useValue: mock() }, diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 0b1c1d117bb7..08a2b566ce5a 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -80,6 +80,9 @@ import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult, + AddItemDialogCloseResult, + AddItemDialogComponent, + AddItemDialogResult, AttachmentDialogResult, AttachmentsV2Component, CipherFormConfig, @@ -951,6 +954,28 @@ export class VaultComponent implements OnInit, OnDestr ); } + /** + * Opens the add-item type selection dialog and handles the result. + */ + protected async openAddItemDialog(): Promise { + const ref = AddItemDialogComponent.open(this.dialogService, { + canCreateFolder: true, + canCreateCollection: this.canCreateCollections, + canCreateSshKey: true, + }); + const result: AddItemDialogCloseResult | undefined = await firstValueFrom(ref.closed); + if (!result) { + return; + } + if (result.result === AddItemDialogResult.Cipher) { + await this.addCipher(result.cipherType); + } else if (result.result === AddItemDialogResult.Folder) { + this.addFolder(); + } else { + await this.addCollection(); + } + } + /** * Opens the add cipher dialog. * @param cipherType The type of cipher to add. From e029cdecdce69b7237af214436d227a976105976 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 09:15:11 -0500 Subject: [PATCH 06/39] add dialog header start slot for dialog --- libs/components/src/dialog/dialog/dialog.component.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 8c1f997df8a7..6a684360aab8 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -11,7 +11,7 @@ > @let showHeaderBorder = bodyHasScrolledFrom().top;
+

-
+
@if (!this.dialogRef?.disableClose) { From ab371dd6382e6c66fe4de55cb5e68596a1be3fcc Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 09:21:31 -0500 Subject: [PATCH 07/39] fix menu trigger when feature flag is disabled --- .../components/new-cipher-menu/new-cipher-menu.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 64f1b111ae17..f58a00ea152c 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -20,7 +20,7 @@ bitButton buttonType="primary" type="button" - [bitMenuTriggerFor]="isOnlyCollectionCreation() ? addOptions : null" + [bitMenuTriggerFor]="isOnlyCollectionCreation() ? null : addOptions" (click)="handleButtonClick()" id="newItemDropdown" [appA11yTitle]="getButtonLabel() | i18n" From a56030cb876621fc3e2f0469872dce06cb520946 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 09:23:20 -0500 Subject: [PATCH 08/39] add back button for cipher dialogs --- .../collections/vault.component.ts | 10 +++++----- .../vault-item-dialog.component.html | 14 +++++++++++++- .../vault-item-dialog.component.ts | 17 +++++++++++++++++ .../vault/individual-vault/vault.component.ts | 10 ++++++---- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 46247709632f..3b88027ac171 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -855,14 +855,14 @@ export class VaultComponent implements OnInit, OnDestroy { return; } if (result.result === AddItemDialogResult.Cipher) { - await this.addCipher(result.cipherType); + await this.addCipher(result.cipherType, true); } else if (result.result === AddItemDialogResult.Collection) { await this.addCollection(); } } /** Opens the Add/Edit Dialog */ - async addCipher(cipherType?: CipherType) { + async addCipher(cipherType?: CipherType, addItemDialogOnClose?: boolean) { const cipherFormConfig = await this.cipherFormConfigService.buildConfig( "add", undefined, @@ -877,7 +877,7 @@ export class VaultComponent implements OnInit, OnDestroy { collectionIds: collectionId ? [collectionId] : [], }; - await this.openVaultItemDialog("form", cipherFormConfig); + await this.openVaultItemDialog("form", cipherFormConfig, undefined, undefined, addItemDialogOnClose); } /** @@ -945,7 +945,7 @@ export class VaultComponent implements OnInit, OnDestroy { formConfig: CipherFormConfig, cipher?: CipherView, activeCollectionId?: CollectionId, - showBackButton?: boolean, + addItemDialogOnClose?: boolean, ) { this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { mode, @@ -953,7 +953,7 @@ export class VaultComponent implements OnInit, OnDestroy { activeCollectionId, isAdminConsoleAction: true, restore: this.restore, - showBackButton, + backAction: addItemDialogOnClose ? this.openAddItemDialog : undefined, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index ea0cc76b89c1..e5700bb12962 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -1,5 +1,17 @@ - + @if (hasBackAction) { + + } + {{ title }} diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index cf1312a9f136..ffe2e733be55 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -43,6 +43,7 @@ import { DIALOG_DATA, DialogRef, AsyncActionsModule, + BitIconButtonComponent, ButtonModule, DialogModule, DialogService, @@ -101,6 +102,12 @@ export interface VaultItemDialogParams { * Function to restore a cipher from the trash. */ restore?: (c: CipherViewLike) => Promise; + + /** + * When provided, a "Back" button is shown when creating a new cipher (cipher == null), + * and the provided method will be invoked. + */ + backAction?: () => void; } export const VaultItemDialogResult = { @@ -133,6 +140,7 @@ export type VaultItemDialogResult = UnionOfValues; selector: "app-vault-item-dialog", templateUrl: "vault-item-dialog.component.html", imports: [ + BitIconButtonComponent, ButtonModule, CipherViewComponent, DialogModule, @@ -279,6 +287,15 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); } + protected get hasBackAction() { + return typeof this.params.backAction === "function"; + } + + invokeBackAction() { + this.params.backAction?.(); + this.dialogRef.close(); + } + protected get submitButtonText$(): Observable { return this.userHasPremium$.pipe( map((hasPremium) => diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 08a2b566ce5a..80787ffcb3ee 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -325,7 +325,7 @@ export class VaultComponent implements OnInit, OnDestr private policyService: PolicyService, private premiumUpgradePromptService: PremiumUpgradePromptService, private webVaultPromptService: WebVaultPromptService, - ) {} + ) { } async ngOnInit() { this.trashCleanupWarning = this.i18nService.t( @@ -925,12 +925,14 @@ export class VaultComponent implements OnInit, OnDestr mode: VaultItemDialogMode, formConfig: CipherFormConfig, activeCollectionId?: CollectionId, + navigateBackToItemDialog?: boolean, ) { this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { mode, formConfig, activeCollectionId, restore: this.restore, + backAction: navigateBackToItemDialog ? this.openAddItemDialog.bind(this) : undefined, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); @@ -968,7 +970,7 @@ export class VaultComponent implements OnInit, OnDestr return; } if (result.result === AddItemDialogResult.Cipher) { - await this.addCipher(result.cipherType); + await this.addCipher(result.cipherType, true); } else if (result.result === AddItemDialogResult.Folder) { this.addFolder(); } else { @@ -980,7 +982,7 @@ export class VaultComponent implements OnInit, OnDestr * Opens the add cipher dialog. * @param cipherType The type of cipher to add. */ - async addCipher(cipherType?: CipherType) { + async addCipher(cipherType?: CipherType, navigateBackToItemDialog?: boolean) { const type = cipherType ?? this.activeFilter.cipherType; const cipherFormConfig = await this.cipherFormConfigService.buildConfig("add", undefined, type); const collectionId = @@ -1006,7 +1008,7 @@ export class VaultComponent implements OnInit, OnDestr folderId: this.activeFilter.folderId, }; - await this.openVaultItemDialog("form", cipherFormConfig); + await this.openVaultItemDialog("form", cipherFormConfig, undefined, navigateBackToItemDialog); } async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) { From 116d05aae24459d70453a9d5450d6ae1ffff6244 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 09:30:26 -0500 Subject: [PATCH 09/39] bind `this` for back action --- .../organizations/collections/vault.component.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 3b88027ac171..0631cd1a7bba 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -877,7 +877,13 @@ export class VaultComponent implements OnInit, OnDestroy { collectionIds: collectionId ? [collectionId] : [], }; - await this.openVaultItemDialog("form", cipherFormConfig, undefined, undefined, addItemDialogOnClose); + await this.openVaultItemDialog( + "form", + cipherFormConfig, + undefined, + undefined, + addItemDialogOnClose, + ); } /** @@ -953,7 +959,7 @@ export class VaultComponent implements OnInit, OnDestroy { activeCollectionId, isAdminConsoleAction: true, restore: this.restore, - backAction: addItemDialogOnClose ? this.openAddItemDialog : undefined, + backAction: addItemDialogOnClose ? this.openAddItemDialog.bind(this) : undefined, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); From eb597c408abd9b6fe5e942ba511c6b4f5cf3d0fd Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 09:51:36 -0500 Subject: [PATCH 10/39] only show back button when a new cipher is being created --- .../vault-item-dialog/vault-item-dialog.component.html | 2 +- .../vault-item-dialog/vault-item-dialog.component.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index e5700bb12962..c4401538ee42 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -1,5 +1,5 @@ - @if (hasBackAction) { + @if (showBackButton) { + } {{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.spec.ts new file mode 100644 index 000000000000..ac8f25f1fcd7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.spec.ts @@ -0,0 +1,104 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { + CollectionAdminService, + CollectionService, + OrganizationUserApiService, +} 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; + +import { GroupApiService } from "../../../core"; + +import { + CollectionDialogAction, + CollectionDialogComponent, + CollectionDialogParams, +} from "./collection-dialog.component"; + +describe("CollectionDialogComponent", () => { + let fixture: ComponentFixture; + + let mockDialogRef: MockProxy>; + + const mockOrgId = newGuid() as OrganizationId; + + const defaultParams: CollectionDialogParams = { + organizationId: mockOrgId, + }; + + beforeEach(async () => { + mockDialogRef = mock>(); + + await TestBed.configureTestingModule({ + imports: [CollectionDialogComponent], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: defaultParams }, + { provide: OrganizationService, useValue: mock() }, + { provide: GroupApiService, useValue: mock() }, + { provide: CollectionAdminService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: OrganizationUserApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: CollectionService, useValue: mock() }, + { provide: ConfigService, useValue: mock() }, + { provide: DialogRef, useValue: mockDialogRef }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CollectionDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("backAction getter", () => { + const backButton = () => + fixture.debugElement.query(By.css("[data-testid='collection-dialog-back-button']")); + + it("does not show the back button when no backAction param is provided", async () => { + expect(backButton()).toBeNull(); + }); + + describe("when backAction param is provided", () => { + let mockBackAction: jest.Mock; + + beforeEach(() => { + mockBackAction = jest.fn(); + defaultParams.backAction = mockBackAction; + fixture.detectChanges(); + }); + + afterEach(() => { + defaultParams.backAction = undefined; + }); + + it("calls the provided backAction when invoked", async () => { + backButton()?.triggerEventHandler("click", null); + + expect(mockBackAction).toHaveBeenCalledTimes(1); + }); + + it("closes the dialog with Canceled action when invoked", async () => { + backButton()?.triggerEventHandler("click", null); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + action: CollectionDialogAction.Canceled, + collection: undefined, + }); + }); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 2f9ddddd8cb1..17472f549399 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -102,6 +102,10 @@ export interface CollectionDialogParams { readonly?: boolean; isAddAccessCollection?: boolean; isAdminConsoleActive?: boolean; + /** + * When provided, a "Back" button is shown and the provided method will be invoked. + */ + backAction?: () => void; } export interface CollectionDialogResult { @@ -345,6 +349,17 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return this.formGroup.controls.selectedOrg; } + protected get backAction() { + if (!this.params.backAction) { + return undefined; + } + + return () => { + this.params.backAction!(); + this.close(CollectionDialogAction.Canceled); + }; + } + protected get isExternalIdVisible(): boolean { return this.params.isAdminConsoleActive && !!this.formGroup.get("externalId")?.value; } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 80787ffcb3ee..327a534ef2c7 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -325,7 +325,7 @@ export class VaultComponent implements OnInit, OnDestr private policyService: PolicyService, private premiumUpgradePromptService: PremiumUpgradePromptService, private webVaultPromptService: WebVaultPromptService, - ) { } + ) {} async ngOnInit() { this.trashCleanupWarning = this.i18nService.t( @@ -974,7 +974,7 @@ export class VaultComponent implements OnInit, OnDestr } else if (result.result === AddItemDialogResult.Folder) { this.addFolder(); } else { - await this.addCollection(); + await this.addCollection(true); } } @@ -1100,7 +1100,7 @@ export class VaultComponent implements OnInit, OnDestr ); } - async addCollection() { + async addCollection(navigateBackToItemDialog?: boolean): Promise { const dialog = openCollectionDialog(this.dialogService, { data: { organizationId: this.allOrganizations @@ -1109,6 +1109,7 @@ export class VaultComponent implements OnInit, OnDestr parentCollectionId: this.filter.collectionId, showOrgSelector: true, limitNestedCollections: true, + backAction: navigateBackToItemDialog ? this.openAddItemDialog.bind(this) : undefined, }, }); const result = await lastValueFrom(dialog.closed); From 009b342ee24894b65119f5e57cc62b27b0226589 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 10:48:51 -0500 Subject: [PATCH 14/39] add back action to folder dialog --- .../vault/individual-vault/vault.component.ts | 8 +++-- .../add-edit-folder-dialog.component.html | 13 +++++++ .../add-edit-folder-dialog.component.spec.ts | 35 +++++++++++++++++++ .../add-edit-folder-dialog.component.ts | 13 +++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 327a534ef2c7..0075d4136de2 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -832,8 +832,10 @@ export class VaultComponent implements OnInit, OnDestr await this.filterComponent()?.filters?.organizationFilter?.action(orgNode); } - addFolder = (): void => { - AddEditFolderDialogComponent.open(this.dialogService); + addFolder = (navigateToAddItemDialog?: boolean): void => { + AddEditFolderDialogComponent.open(this.dialogService, { + backAction: navigateToAddItemDialog ? this.openAddItemDialog.bind(this) : undefined, + }); }; editFolder = async (folder: FolderFilter): Promise => { @@ -972,7 +974,7 @@ export class VaultComponent implements OnInit, OnDestr if (result.result === AddItemDialogResult.Cipher) { await this.addCipher(result.cipherType, true); } else if (result.result === AddItemDialogResult.Folder) { - this.addFolder(); + this.addFolder(true); } else { await this.addCollection(true); } diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html index 03f32c1b5152..31a869a7266e 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html @@ -1,4 +1,17 @@ + @if (backAction) { + + } {{ (variant === "add" ? "newFolder" : "editFolder") | i18n }} diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts index a783bdc74068..587b45536211 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts @@ -177,4 +177,39 @@ describe("AddEditFolderDialogComponent", () => { expect(close).toHaveBeenCalledWith(AddEditFolderDialogResult.Deleted); }); }); + + describe("backAction getter", () => { + const backButton = () => + fixture.nativeElement.querySelector("[data-testid='folder-dialog-back-button']"); + + it("does not show the back button when no backAction is provided", () => { + expect(backButton()).toBeNull(); + }); + + describe("when backAction is provided", () => { + const mockBackAction = jest.fn(); + + beforeEach(() => { + dialogData.backAction = mockBackAction; + fixture.detectChanges(); + }); + + afterEach(() => { + delete dialogData.backAction; + mockBackAction.mockClear(); + }); + + it("calls the provided backAction when clicked", () => { + backButton().click(); + + expect(mockBackAction).toHaveBeenCalledTimes(1); + }); + + it("closes the dialog when clicked", () => { + backButton().click(); + + expect(close).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts index 759277dd4b89..8fa0a1023dca 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -45,6 +45,8 @@ export type AddEditFolderDialogResult = UnionOfValues void; }; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -117,6 +119,17 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { }); } + protected get backAction() { + if (!this.data?.backAction) { + return undefined; + } + + return () => { + this.data.backAction(); + this.dialogRef.close(); + }; + } + /** Submit the new folder */ submit = async () => { if (this.folderForm.invalid) { From 0eed04081bda0c5ee7486c94c8bc7f7fadbd0dcd Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 13:56:53 -0500 Subject: [PATCH 15/39] fix formatting and strict types --- .../collections/vault-header/vault-header.component.ts | 4 ++-- .../add-edit-folder-dialog.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts index fdaca3ef5741..0235979b4067 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts @@ -109,7 +109,7 @@ export class VaultHeaderComponent { // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() searchTextChanged = new EventEmitter(); - /** Emits an event when the add item dialog should be opened */ + /** Emits an event when the add item dialog should be opened */ onOpenAddItemDialog = output(); protected CollectionDialogTabType = CollectionDialogTabType; @@ -123,7 +123,7 @@ export class VaultHeaderComponent { private collectionAdminService: CollectionAdminService, private router: Router, private accountService: AccountService, - ) { } + ) {} get title() { const headerType = this.i18nService.t("collections").toLowerCase(); diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts index 8fa0a1023dca..2635f879be57 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -125,7 +125,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { } return () => { - this.data.backAction(); + this.data?.backAction?.(); this.dialogRef.close(); }; } From caf356f172ae39c8129c0b48b6dc49c09b96c376 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 13:58:23 -0500 Subject: [PATCH 16/39] update backAction getter for consistency --- .../vault-item-dialog.component.html | 4 ++-- .../vault-item-dialog.component.ts | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index c4401538ee42..a736dbd74e1e 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -1,12 +1,12 @@ - @if (showBackButton) { + @if (backAction) { diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 0443782f77b3..989a19a2c7f1 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -287,17 +287,16 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); } - protected get showBackButton() { - return ( + protected get backAction() { + if ( typeof this.params.backAction === "function" && this.cipher == null && this.params.mode === "form" - ); - } + ) { + return this.params.backAction; + } - invokeBackAction() { - this.params.backAction?.(); - this.dialogRef.close(); + return undefined; } protected get submitButtonText$(): Observable { From c746672f35517baa5f6e6d4ed1aabb363f635018 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 2 Apr 2026 14:32:23 -0500 Subject: [PATCH 17/39] add story without footer --- .../src/dialog/dialog/dialog.component.html | 22 +++++++++---------- .../src/dialog/dialog/dialog.stories.ts | 15 +++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 6a684360aab8..d6c3ad87ac5e 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -80,18 +80,18 @@
- @let isScrollable = isScrollable$ | async; - @let showFooterBorder = - (!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom; -
@if (hasFooter()) { + @let isScrollable = isScrollable$ | async; + @let showFooterBorder = + (!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom; +