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 71d27fcfb326..d5e3988b39a1 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/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..5305c526c130 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 */ + readonly onOpenAddItemDialog = output(); + protected CollectionDialogTabType = CollectionDialogTabType; /** The cipher type enum. */ @@ -218,6 +221,10 @@ export class VaultHeaderComponent { this.onAddCipher.emit(cipherType); } + protected 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 08a0cfcc5e14..de0af8e060a5 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, @@ -838,6 +841,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( 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..faa54406e8c1 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 @@ - + {{ 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 98b5952456af..9f04cda703be 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, @@ -133,6 +134,7 @@ export type VaultItemDialogResult = UnionOfValues; selector: "app-vault-item-dialog", templateUrl: "vault-item-dialog.component.html", imports: [ + BitIconButtonComponent, ButtonModule, CipherViewComponent, DialogModule, 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 0b2a49ba601f..d7073b1671bf 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 @@ -90,6 +90,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..8edb9cb7667c 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, @@ -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 6461c0ce8ceb..b6a2ea4a7de2 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..94455221fb5d 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. @@ -1073,7 +1098,7 @@ export class VaultComponent implements OnInit, OnDestr ); } - async addCollection() { + async addCollection(): Promise { const dialog = openCollectionDialog(this.dialogService, { data: { organizationId: this.allOrganizations diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9eb1dc50c11e..e6368a75da9d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -988,6 +988,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" }, @@ -1057,6 +1078,9 @@ "addItem": { "message": "Add item" }, + "chooseItemToAdd": { + "message": "Choose item to add" + }, "editItem": { "message": "Edit item" }, @@ -13251,4 +13275,4 @@ } } } -} \ No newline at end of file +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a9fdd0dbf257..fafe2bbe407d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -70,6 +70,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", @@ -141,6 +142,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE, /* Vault */ + [FeatureFlag.PM32009NewItemTypes]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, diff --git a/libs/common/src/vault/types/cipher-menu-items.ts b/libs/common/src/vault/types/cipher-menu-items.ts index 7108d0d0bd68..e3bcbba2bde6 100644 --- a/libs/common/src/vault/types/cipher-menu-items.ts +++ b/libs/common/src/vault/types/cipher-menu-items.ts @@ -10,15 +10,63 @@ 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[]; + +/** + * Updated menu items for new item dialog. This list should only be used + * when `FeatureFlag.PM32009NewItemTypes` is enabled, otherwise use `CIPHER_MENU_ITEMS`. + */ +export const DIALOG_CIPHER_MENU_ITEMS = CIPHER_MENU_ITEMS.map((item) => { + if (item.type === CipherType.Login) { + return { + ...item, + icon: "bwi-lock", + }; + } + + if (item.type === CipherType.Identity) { + return { + ...item, + icon: "bwi-user", + }; + } + return item; +}); diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index cb95186b3bd8..eacc2f43042f 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-4': !hasFooter() && !disablePadding(), 'tw-overflow-y-auto': !loading(), 'tw-invisible tw-overflow-y-hidden': loading(), 'tw-py-4': background() === 'alt', @@ -78,24 +79,26 @@
- @let isScrollable = isScrollable$ | async; - @let showFooterBorder = - (!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom; -
-
- -
+ @if (hasFooter()) { + @let isScrollable = isScrollable$ | async; + @let showFooterBorder = + (!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom; +
+
+ +
+ } 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"); diff --git a/libs/components/src/dialog/dialog/dialog.stories.ts b/libs/components/src/dialog/dialog/dialog.stories.ts index f3321b924fd1..a33f81968e16 100644 --- a/libs/components/src/dialog/dialog/dialog.stories.ts +++ b/libs/components/src/dialog/dialog/dialog.stories.ts @@ -290,6 +290,21 @@ export const WithCards: Story = { }, }; +export const WithoutFooter: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + Dialog body text goes here. + + `, + }), + args: { + dialogSize: "default", + title: "Without Footer", + }, +}; + export const HeaderEnd: Story = { render: (args) => ({ props: args, 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..26eb6d47bb15 --- /dev/null +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.html @@ -0,0 +1,13 @@ + + + {{ "chooseItemToAdd" | 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..b619dbbe0557 --- /dev/null +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +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 { AddItemGridComponent } from "../add-item-grid/add-item-grid.component"; + +import { + AddItemDialogComponent, + AddItemDialogData, + AddItemDialogResult, +} from "./add-item-dialog.component"; + +describe("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); + fixture.detectChanges(); + } + + function getGrid(): AddItemGridComponent { + return fixture.debugElement.query(By.directive(AddItemGridComponent)).componentInstance; + } + + it("closes with cipher result when a cipher type is selected", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: true, + }); + + getGrid().itemSelected.emit({ + result: AddItemDialogResult.Cipher, + cipherType: CipherType.Login, + }); + + expect(close).toHaveBeenCalledWith({ + result: AddItemDialogResult.Cipher, + cipherType: CipherType.Login, + }); + }); + + it("closes with folder result when a folder is selected", () => { + createComponent({ + canCreateFolder: true, + canCreateCollection: false, + canCreateSshKey: false, + }); + + getGrid().itemSelected.emit({ result: AddItemDialogResult.Folder }); + + expect(close).toHaveBeenCalledWith({ result: AddItemDialogResult.Folder }); + }); + + it("closes with collection result when a collection is selected", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: true, + canCreateSshKey: false, + }); + + getGrid().itemSelected.emit({ result: AddItemDialogResult.Collection }); + + 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..c410b5a132be --- /dev/null +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; + +import { DIALOG_DATA, DialogModule, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AddItemGridComponent, AddItemGridResult } from "../add-item-grid/add-item-grid.component"; + +export { AddItemGridResult as AddItemDialogResult } from "../add-item-grid/add-item-grid.component"; +export type AddItemDialogCloseResult = AddItemGridResult; + +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 { + protected readonly dialogRef = inject>(DialogRef); + protected readonly data = inject(DIALOG_DATA); + + protected onItemSelected(closeResult: AddItemDialogCloseResult): void { + this.dialogRef.close(closeResult); + } + + 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..3d2d0f89ec7b --- /dev/null +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.html @@ -0,0 +1,28 @@ +@let allItems = items(); +
    + @for (item of allItems; 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..c53948c22ac1 --- /dev/null +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts @@ -0,0 +1,178 @@ +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, AddItemGridResult } from "./add-item-grid.component"; + +describe("AddItemGridComponent", () => { + 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 itemSelected with cipher result when a cipher type is selected", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: false, + canCreateSshKey: true, + }); + + const itemSelected = jest.fn(); + component.itemSelected.subscribe(itemSelected); + + const loginItem = component["items"]().find((i) => i.labelKey === "typeLogin"); + loginItem!.action(); + + expect(itemSelected).toHaveBeenCalledWith({ + result: AddItemGridResult.Cipher, + cipherType: CipherType.Login, + }); + }); + + it("emits itemSelected with folder result when folder is selected", () => { + createComponent({ + canCreateFolder: true, + canCreateCollection: false, + canCreateSshKey: false, + }); + + const itemSelected = jest.fn(); + component.itemSelected.subscribe(itemSelected); + + const folderItem = component["items"]().find((i) => i.labelKey === "folder"); + folderItem!.action(); + + expect(itemSelected).toHaveBeenCalledWith({ result: AddItemGridResult.Folder }); + }); + + it("emits itemSelected with collection result when collection is selected", () => { + createComponent({ + canCreateFolder: false, + canCreateCollection: true, + canCreateSshKey: false, + }); + + const itemSelected = jest.fn(); + component.itemSelected.subscribe(itemSelected); + + const collectionItem = component["items"]().find((i) => i.labelKey === "collection"); + collectionItem!.action(); + + expect(itemSelected).toHaveBeenCalledWith({ result: AddItemGridResult.Collection }); + }); +}); 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..1fc345ab4b43 --- /dev/null +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.ts @@ -0,0 +1,92 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, input, output } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; + +import { CipherType } from "@bitwarden/common/vault/enums"; +import { + RestrictedCipherType, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DIALOG_CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +import { + BitwardenIcon, + IconComponent, + ItemModule, + TypographyModule, + IconTileComponent, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export const AddItemGridResult = Object.freeze({ + Cipher: "cipher", + Folder: "folder", + Collection: "collection", +} as const); + +export type AddItemGridResult = + | { result: typeof AddItemGridResult.Cipher; cipherType: CipherType } + | { result: typeof AddItemGridResult.Folder } + | { result: typeof AddItemGridResult.Collection }; + +type GridItem = { + 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, I18nPipe, IconTileComponent, IconComponent, ItemModule, TypographyModule], +}) +export class AddItemGridComponent { + readonly canCreateFolder = input(false); + readonly canCreateCollection = input(false); + readonly canCreateSshKey = input(false); + + readonly itemSelected = output(); + + private readonly restrictedTypes = toSignal(this.restrictedItemTypesService.restricted$, { + initialValue: [] as RestrictedCipherType[], + }); + + protected readonly items = computed(() => { + const restrictedTypes = this.restrictedTypes(); + const items: GridItem[] = DIALOG_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 as BitwardenIcon, + labelKey: item.labelKey, + subtitleKey: item.subtitleKey, + action: () => + this.itemSelected.emit({ result: AddItemGridResult.Cipher, cipherType: item.type }), + })); + + if (this.canCreateFolder()) { + items.push({ + icon: "bwi-folder", + labelKey: "folder", + subtitleKey: "folderSubtitle", + action: () => this.itemSelected.emit({ result: AddItemGridResult.Folder }), + }); + } + + if (this.canCreateCollection()) { + items.push({ + icon: "bwi-collection-shared", + labelKey: "collection", + subtitleKey: "collectionSubtitle", + action: () => this.itemSelected.emit({ result: AddItemGridResult.Collection }), + }); + } + + 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..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 @@ -1,4 +1,20 @@ - +@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..a00e85f77a65 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"; @@ -24,18 +26,10 @@ import { I18nPipe } from "@bitwarden/ui-common"; imports: [ButtonModule, CommonModule, MenuModule, PopoverModule, I18nPipe, JslibModule], }) export class NewCipherMenuComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - canCreateCipher = input(false); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - canCreateFolder = input(false); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - canCreateCollection = input(false); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - canCreateSshKey = input(false); + readonly canCreateCipher = input(false); + readonly canCreateFolder = input(false); + readonly canCreateCollection = input(false); + readonly canCreateSshKey = input(false); /** Optional popover to anchor to the "New" button for coachmark tours */ readonly coachmarkPopover = input(); @@ -47,8 +41,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 +109,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";