diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.html b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.html new file mode 100644 index 0000000000..6aa3e61064 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.html @@ -0,0 +1,34 @@ + + + {{ headerText }} + + + + + + + + + + + + + diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.spec.ts b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.spec.ts new file mode 100644 index 0000000000..cd3fec7377 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.spec.ts @@ -0,0 +1,124 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; +import { CipherFormConfig, DefaultCipherFormConfigService } from "@bitwarden/vault"; + +import { AddEditComponentV2 } from "./add-edit-v2.component"; + +describe("AddEditComponentV2", () => { + let component: AddEditComponentV2; + let fixture: ComponentFixture; + let organizationService: MockProxy; + let policyService: MockProxy; + let billingAccountProfileStateService: MockProxy; + let activatedRoute: MockProxy; + let dialogRef: MockProxy>; + let dialogService: MockProxy; + let cipherService: MockProxy; + let messagingService: MockProxy; + let folderService: MockProxy; + let collectionService: MockProxy; + + const mockParams = { + cloneMode: false, + cipherFormConfig: mock(), + }; + + beforeEach(async () => { + const mockOrganization: Organization = { + id: "org-id", + name: "Test Organization", + } as Organization; + + organizationService = mock(); + organizationService.organizations$ = of([mockOrganization]); + + policyService = mock(); + policyService.policyAppliesToActiveUser$.mockImplementation((policyType: PolicyType) => + of(true), + ); + + billingAccountProfileStateService = mock(); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); + + activatedRoute = mock(); + activatedRoute.queryParams = of({}); + + dialogRef = mock>(); + dialogService = mock(); + messagingService = mock(); + folderService = mock(); + folderService.folderViews$ = of([]); + collectionService = mock(); + collectionService.decryptedCollections$ = of([]); + + const mockDefaultCipherFormConfigService = { + buildConfig: jest.fn().mockResolvedValue({ + allowPersonal: true, + allowOrganization: true, + }), + }; + + await TestBed.configureTestingModule({ + imports: [AddEditComponentV2], + providers: [ + { provide: DIALOG_DATA, useValue: mockParams }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } }, + { provide: DialogService, useValue: dialogService }, + { provide: CipherService, useValue: cipherService }, + { provide: MessagingService, useValue: messagingService }, + { provide: OrganizationService, useValue: organizationService }, + { provide: Router, useValue: mock() }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: CollectionService, useValue: collectionService }, + { provide: FolderService, useValue: folderService }, + { provide: CryptoService, useValue: mock() }, + { provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService }, + { provide: PolicyService, useValue: policyService }, + { provide: DefaultCipherFormConfigService, useValue: mockDefaultCipherFormConfigService }, + { + provide: PasswordGenerationServiceAbstraction, + useValue: mock(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddEditComponentV2); + component = fixture.componentInstance; + }); + + describe("ngOnInit", () => { + it("initializes the component with cipher", async () => { + await component.ngOnInit(); + + expect(component).toBeTruthy(); + }); + }); + + describe("cancel", () => { + it("handles cancel action", async () => { + const spyClose = jest.spyOn(dialogRef, "close"); + + await component.cancel(); + + expect(spyClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts new file mode 100644 index 0000000000..64935c8af3 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts @@ -0,0 +1,177 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { AsyncActionsModule, DialogModule, DialogService, ItemModule } from "@bitwarden/components"; +import { + CipherAttachmentsComponent, + CipherFormConfig, + CipherFormGenerationService, + CipherFormMode, + CipherFormModule, +} from "@bitwarden/vault"; + +import { WebCipherFormGenerationService } from "../../../../../../libs/vault/src/cipher-form/services/web-cipher-form-generation.service"; +import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; +import { SharedModule } from "../../shared/shared.module"; + +import { AttachmentsV2Component } from "./attachments-v2.component"; + +/** + * The result of the AddEditCipherDialogV2 component. + */ +export enum AddEditCipherDialogResult { + Edited = "edited", + Added = "added", + Canceled = "canceled", +} + +/** + * The close result of the AddEditCipherDialogV2 component. + */ +export interface AddEditCipherDialogCloseResult { + /** + * The action that was taken. + */ + action: AddEditCipherDialogResult; + /** + * The ID of the cipher that was edited or added. + */ + id?: CipherId; +} + +/** + * Component for viewing a cipher, presented in a dialog. + */ +@Component({ + selector: "app-vault-add-edit-v2", + templateUrl: "add-edit-v2.component.html", + standalone: true, + imports: [ + CipherViewComponent, + CommonModule, + AsyncActionsModule, + DialogModule, + SharedModule, + CipherFormModule, + CipherAttachmentsComponent, + ItemModule, + ], + providers: [{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }], +}) +export class AddEditComponentV2 implements OnInit { + config: CipherFormConfig; + headerText: string; + canAccessAttachments: boolean = false; + + /** + * Constructor for the AddEditComponentV2 component. + * @param params The parameters for the component. + * @param dialogRef The reference to the dialog. + * @param i18nService The internationalization service. + * @param dialogService The dialog service. + * @param billingAccountProfileStateService The billing account profile state service. + */ + constructor( + @Inject(DIALOG_DATA) public params: CipherFormConfig, + private dialogRef: DialogRef, + private i18nService: I18nService, + private dialogService: DialogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) { + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntilDestroyed()) + .subscribe((canAccessPremium) => { + this.canAccessAttachments = canAccessPremium; + }); + } + + /** + * Lifecycle hook for component initialization. + */ + async ngOnInit() { + this.config = this.params; + this.headerText = this.setHeader(this.config?.mode, this.config.cipherType); + } + + /** + * Getter to check if the component is loading. + */ + get loading() { + return this.config == null; + } + + /** + * Method to handle cancel action. Called when a user clicks the cancel button. + */ + async cancel() { + this.dialogRef.close({ action: AddEditCipherDialogResult.Canceled }); + } + + /** + * Sets the header text based on the mode and type of the cipher. + * @param mode The form mode. + * @param type The cipher type. + * @returns The header text. + */ + setHeader(mode: CipherFormMode, type: CipherType) { + const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader"; + switch (type) { + case CipherType.Login: + return this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase()); + case CipherType.Card: + return this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase()); + case CipherType.Identity: + return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase()); + case CipherType.SecureNote: + return this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase()); + } + } + + /** + * Opens the attachments dialog. + */ + async openAttachmentsDialog() { + this.dialogService.open( + AttachmentsV2Component, + { + data: { + cipherId: this.config.originalCipher?.id as CipherId, + }, + }, + ); + } + + /** + * Handles the event when a cipher is saved. + * @param cipherView The cipher view that was saved. + */ + async onCipherSaved(cipherView: CipherView) { + this.dialogRef.close({ + action: + this.config.mode === "add" + ? AddEditCipherDialogResult.Added + : AddEditCipherDialogResult.Edited, + id: cipherView.id as CipherId, + }); + } +} + +/** + * Strongly typed helper to open a cipher add/edit dialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + * @returns A reference to the opened dialog + */ +export function openAddEditCipherDialog( + dialogService: DialogService, + config: DialogConfig, +): DialogRef { + return dialogService.open(AddEditComponentV2, config); +} diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.html b/apps/web/src/app/vault/individual-vault/attachments-v2.component.html new file mode 100644 index 0000000000..532a0224be --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.html @@ -0,0 +1,19 @@ + + + {{ "attachments" | i18n }} + + + + + + + + diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts b/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts new file mode 100644 index 0000000000..8099d8fe92 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts @@ -0,0 +1,65 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; + +import { + AttachmentsV2Component, + AttachmentDialogResult, + AttachmentsDialogParams, +} from "./attachments-v2.component"; + +describe("AttachmentsV2Component", () => { + let component: AttachmentsV2Component; + let fixture: ComponentFixture; + + const mockCipherId: CipherId = "cipher-id" as CipherId; + const mockParams: AttachmentsDialogParams = { + cipherId: mockCipherId, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AttachmentsV2Component, NoopAnimationsModule], + providers: [ + { provide: DIALOG_DATA, useValue: mockParams }, + { provide: DialogRef, useValue: mock() }, + { provide: I18nService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AttachmentsV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("initializes without errors and with the correct cipherId", () => { + expect(component).toBeTruthy(); + expect(component.cipherId).toBe(mockParams.cipherId); + }); + + it("closes the dialog with 'uploaded' result on uploadSuccessful", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.uploadSuccessful(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Uploaded }); + }); + + it("closes the dialog with 'removed' result on removalSuccessful", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.removalSuccessful(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts b/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts new file mode 100644 index 0000000000..e3b974e6c0 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts @@ -0,0 +1,87 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { CipherId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; +import { CipherAttachmentsComponent } from "@bitwarden/vault"; + +import { SharedModule } from "../../shared"; + +export interface AttachmentsDialogParams { + cipherId: CipherId; +} + +/** + * Enum representing the possible results of the attachment dialog. + */ +export enum AttachmentDialogResult { + Uploaded = "uploaded", + Removed = "removed", + Closed = "closed", +} + +export interface AttachmentDialogCloseResult { + action: AttachmentDialogResult; +} + +/** + * Component for the attachments dialog. + */ +@Component({ + selector: "app-vault-attachments-v2", + templateUrl: "attachments-v2.component.html", + standalone: true, + imports: [CommonModule, SharedModule, CipherAttachmentsComponent], +}) +export class AttachmentsV2Component { + cipherId: CipherId; + attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + + /** + * Constructor for AttachmentsV2Component. + * @param dialogRef - Reference to the dialog. + * @param params - Parameters passed to the dialog. + */ + constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, + ) { + this.cipherId = params.cipherId; + } + + /** + * Opens the attachments dialog. + * @param dialogService - The dialog service. + * @param params - The parameters for the dialog. + * @returns The dialog reference. + */ + static open( + dialogService: DialogService, + params: AttachmentsDialogParams, + ): DialogRef { + return dialogService.open(AttachmentsV2Component, { + data: params, + }); + } + + /** + * Called when an attachment is successfully uploaded. + * Closes the dialog with an 'uploaded' result. + */ + uploadSuccessful() { + this.dialogRef.close({ + action: AttachmentDialogResult.Uploaded, + }); + } + + /** + * Called when an attachment is successfully removed. + * Closes the dialog with a 'removed' result. + */ + removalSuccessful() { + this.dialogRef.close({ + action: AttachmentDialogResult.Removed, + }); + } +} 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 dcf62235d1..a288b298ba 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -47,20 +47,25 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { DialogService, Icons, ToastService } from "@bitwarden/components"; -import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault"; +import { + CollectionAssignmentResult, + DefaultCipherFormConfigService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { SharedModule } from "../../shared/shared.module"; import { AssignCollectionsWebComponent } from "../components/assign-collections"; @@ -74,7 +79,17 @@ import { VaultItemEvent } from "../components/vault-items/vault-item-event"; import { VaultItemsModule } from "../components/vault-items/vault-items.module"; import { getNestedCollectionTree } from "../utils/collection-utils"; +import { + AddEditCipherDialogCloseResult, + AddEditCipherDialogResult, + openAddEditCipherDialog, +} from "./add-edit-v2.component"; import { AddEditComponent } from "./add-edit.component"; +import { + AttachmentDialogCloseResult, + AttachmentDialogResult, + AttachmentsV2Component, +} from "./attachments-v2.component"; import { AttachmentsComponent } from "./attachments.component"; import { BulkDeleteDialogResult, @@ -131,7 +146,11 @@ const SearchTextDebounceInterval = 200; VaultItemsModule, SharedModule, ], - providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService], + providers: [ + RoutedVaultFilterService, + RoutedVaultFilterBridgeService, + DefaultCipherFormConfigService, + ], }) export class VaultComponent implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; @@ -170,6 +189,7 @@ export class VaultComponent implements OnInit, OnDestroy { private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); + private extensionRefreshEnabled: boolean; constructor( private syncService: SyncService, @@ -200,6 +220,7 @@ export class VaultComponent implements OnInit, OnDestroy { private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, private accountService: AccountService, + private cipherFormConfigService: DefaultCipherFormConfigService, ) {} async ngOnInit() { @@ -416,6 +437,11 @@ export class VaultComponent implements OnInit, OnDestroy { this.refreshing = false; }, ); + + // Check if the extension refresh feature flag is enabled + this.extensionRefreshEnabled = await this.configService.getFeatureFlag( + FeatureFlag.ExtensionRefresh, + ); } ngOnDestroy() { @@ -511,6 +537,15 @@ export class VaultComponent implements OnInit, OnDestroy { this.searchText$.next(searchText); } + /** + * Handles opening the attachments dialog for a cipher. + * Runs several checks to ensure that the user has the correct permissions + * and then opens the attachments dialog. + * Uses the new AttachmentsV2Component if the extensionRefresh feature flag is enabled. + * + * @param cipher + * @returns + */ async editCipherAttachments(cipher: CipherView) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { this.go({ cipherId: null, itemId: null }); @@ -536,6 +571,24 @@ export class VaultComponent implements OnInit, OnDestroy { ); let madeAttachmentChanges = false; + + if (this.extensionRefreshEnabled) { + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: cipher.id as CipherId, + }); + + const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); + + if ( + result.action === AttachmentDialogResult.Uploaded || + result.action === AttachmentDialogResult.Removed + ) { + this.refresh(); + } + + return; + } + const [modal] = await this.modalService.openViewRef( AttachmentsComponent, this.attachmentsModalRef, @@ -598,7 +651,11 @@ export class VaultComponent implements OnInit, OnDestroy { } async addCipher(cipherType?: CipherType) { - const component = await this.editCipher(null); + if (this.extensionRefreshEnabled) { + return this.addCipherV2(cipherType); + } + + const component = (await this.editCipher(null)) as AddEditComponent; component.type = cipherType || this.activeFilter.cipherType; if ( this.activeFilter.organizationId !== "MyVault" && @@ -622,18 +679,60 @@ export class VaultComponent implements OnInit, OnDestroy { component.folderId = this.activeFilter.folderId; } + /** + * Opens the add cipher dialog. + * @param cipherType The type of cipher to add. + * @returns The dialog reference. + */ + async addCipherV2(cipherType?: CipherType) { + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + "add", + null, + cipherType, + ); + cipherFormConfig.initialValues = { + organizationId: + this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null + ? (this.activeFilter.organizationId as OrganizationId) + : null, + collectionIds: + this.activeFilter.collectionId !== "AllCollections" && + this.activeFilter.collectionId != null + ? [this.activeFilter.collectionId as CollectionId] + : [], + folderId: this.activeFilter.folderId, + }; + + // Open the dialog. + const dialogRef = openAddEditCipherDialog(this.dialogService, { + data: cipherFormConfig, + }); + + // Wait for the dialog to close. + const result: AddEditCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); + + // Refresh the vault to show the new cipher. + if (result?.action === AddEditCipherDialogResult.Added) { + this.refresh(); + this.go({ itemId: result.id, action: "view" }); + return; + } + + // If the dialog was closed by any other action navigate back to the vault. + this.go({ cipherId: null, itemId: null, action: null }); + } + async navigateToCipher(cipher: CipherView) { this.go({ itemId: cipher?.id }); } - async editCipher(cipher: CipherView) { - return this.editCipherId(cipher?.id); + async editCipher(cipher: CipherView, cloneMode?: boolean) { + return this.editCipherId(cipher?.id, cloneMode); } - async editCipherId(id: string) { + async editCipherId(id: string, cloneMode?: boolean) { const cipher = await this.cipherService.get(id); - // if cipher exists (cipher is null when new) and MP reprompt - // is on for this cipher, then show password reprompt + if ( cipher && cipher.reprompt !== 0 && @@ -644,6 +743,11 @@ export class VaultComponent implements OnInit, OnDestroy { return; } + if (this.extensionRefreshEnabled) { + await this.editCipherIdV2(cipher, cloneMode); + return; + } + const [modal, childComponent] = await this.modalService.openViewRef( AddEditComponent, this.cipherAddEditModalRef, @@ -673,6 +777,46 @@ export class VaultComponent implements OnInit, OnDestroy { return childComponent; } + /** + * Edit a cipher using the new AddEditCipherDialogV2 component. + * + * @param cipher + * @param cloneMode + */ + private async editCipherIdV2(cipher: Cipher, cloneMode?: boolean) { + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + cloneMode ? "clone" : "edit", + cipher.id as CipherId, + cipher.type, + ); + + const dialogRef = openAddEditCipherDialog(this.dialogService, { + data: cipherFormConfig, + }); + + const result: AddEditCipherDialogCloseResult = await firstValueFrom(dialogRef.closed); + + /** + * Refresh the vault if the dialog was closed by adding, editing, or deleting a cipher. + */ + if (result?.action === AddEditCipherDialogResult.Edited) { + this.refresh(); + } + + /** + * View the cipher if the dialog was closed by editing the cipher. + */ + if (result?.action === AddEditCipherDialogResult.Edited) { + this.go({ itemId: cipher.id, action: "view" }); + return; + } + + /** + * Navigate to the vault if the dialog was closed by any other action. + */ + this.go({ cipherId: null, itemId: null, action: null }); + } + /** * Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById). * @param cipher - CipherView @@ -718,8 +862,9 @@ export class VaultComponent implements OnInit, OnDestroy { const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); // If the dialog was closed by deleting the cipher, refresh the vault. - if (result?.action === ViewCipherDialogResult.deleted) { + if (result?.action === ViewCipherDialogResult.Deleted) { this.refresh(); + this.go({ cipherId: null, itemId: null, action: null }); } // If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault. @@ -873,7 +1018,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - const component = await this.editCipher(cipher); + const component = await this.editCipher(cipher, true); component.cloneMode = true; } diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index fec97e202e..dfa2d50489 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -98,7 +98,7 @@ describe("ViewComponent", () => { organizationId: mockCipher.organizationId, }, }); - expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited }); + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Edited }); }); }); @@ -111,7 +111,7 @@ describe("ViewComponent", () => { await component.delete(); expect(deleteSpy).toHaveBeenCalled(); - expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted }); + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Deleted }); }); }); }); diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index fe317490c1..964be0e8ab 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -27,8 +27,8 @@ export interface ViewCipherDialogParams { } export enum ViewCipherDialogResult { - edited = "edited", - deleted = "deleted", + Edited = "edited", + Deleted = "deleted", } export interface ViewCipherDialogCloseResult { @@ -117,7 +117,7 @@ export class ViewComponent implements OnInit, OnDestroy { this.logService.error(e); } - this.dialogRef.close({ action: ViewCipherDialogResult.deleted }); + this.dialogRef.close({ action: ViewCipherDialogResult.Deleted }); await this.router.navigate(["/vault"]); }; @@ -137,7 +137,7 @@ export class ViewComponent implements OnInit, OnDestroy { * Method to handle cipher editing. Called when a user clicks the edit button. */ async edit(): Promise { - this.dialogRef.close({ action: ViewCipherDialogResult.edited }); + this.dialogRef.close({ action: ViewCipherDialogResult.Edited }); await this.router.navigate([], { queryParams: { itemId: this.cipher.id, diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index add1ecbe3e..32f7f0eba5 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -886,8 +886,9 @@ export class VaultComponent implements OnInit, OnDestroy { const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); // If the dialog was closed by deleting the cipher, refresh the vault. - if (result.action === ViewCipherDialogResult.deleted) { + if (result.action === ViewCipherDialogResult.Deleted) { this.refresh(); + this.go({ cipherId: null, itemId: null, action: null }); } // If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault. diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1ca7d04082..87c0ad654f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -511,6 +511,24 @@ "viewItem": { "message": "View item" }, + "newItemHeader": { + "message": "New $TYPE$", + "placeholders": { + "type": { + "content": "$1", + "example": "login" + } + } + }, + "editItemHeader": { + "message": "Edit $TYPE$", + "placeholders": { + "type": { + "content": "$1", + "example": "login" + } + } + }, "viewItemType": { "message": "View $ITEMTYPE$", "placeholders": { @@ -7399,6 +7417,9 @@ "fileUpload": { "message": "File upload" }, + "upload": { + "message": "Upload" + }, "acceptedFormats": { "message": "Accepted Formats:" }, diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index a6febe4897..93cca2e5db 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -85,6 +85,9 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { /** Emits after a file has been successfully uploaded */ @Output() onUploadSuccess = new EventEmitter(); + /** Emits after a file has been successfully removed */ + @Output() onRemoveSuccess = new EventEmitter(); + cipher: CipherView; attachmentForm: CipherAttachmentForm = this.formBuilder.group({ @@ -216,5 +219,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { if (index > -1) { this.cipher.attachments.splice(index, 1); } + + this.onRemoveSuccess.emit(); } } diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts index 569a666699..232a4b2d27 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -7,6 +7,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -39,6 +40,8 @@ describe("LoginDetailsSectionComponent", () => { let toastService: MockProxy; let totpCaptureService: MockProxy; let i18nService: MockProxy; + let configService: MockProxy; + const collect = jest.fn().mockResolvedValue(null); beforeEach(async () => { @@ -49,6 +52,7 @@ describe("LoginDetailsSectionComponent", () => { toastService = mock(); totpCaptureService = mock(); i18nService = mock(); + configService = mock(); collect.mockClear(); await TestBed.configureTestingModule({ @@ -60,6 +64,7 @@ describe("LoginDetailsSectionComponent", () => { { provide: ToastService, useValue: toastService }, { provide: TotpCaptureService, useValue: totpCaptureService }, { provide: I18nService, useValue: i18nService }, + { provide: ConfigService, useValue: configService }, { provide: EventCollectionService, useValue: { collect } }, ], }) diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html new file mode 100644 index 0000000000..30259cd640 --- /dev/null +++ b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html @@ -0,0 +1,22 @@ + + + {{ title }} + + + + + + + + diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts new file mode 100644 index 0000000000..844f15a47a --- /dev/null +++ b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -0,0 +1,125 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { UsernameGenerationServiceAbstraction } from "../../../../../../libs/tools/generator/extensions/legacy/src/username-generation.service.abstraction"; +import { CipherFormGeneratorComponent } from "../cipher-generator/cipher-form-generator.component"; + +import { + WebVaultGeneratorDialogComponent, + WebVaultGeneratorDialogParams, + WebVaultGeneratorDialogAction, +} from "./web-generator-dialog.component"; + +describe("WebVaultGeneratorDialogComponent", () => { + let component: WebVaultGeneratorDialogComponent; + let fixture: ComponentFixture; + + let dialogRef: MockProxy>; + let mockI18nService: MockProxy; + let passwordOptionsSubject: BehaviorSubject; + let usernameOptionsSubject: BehaviorSubject; + let mockPasswordGenerationService: MockProxy; + let mockUsernameGenerationService: MockProxy; + + beforeEach(async () => { + dialogRef = mock>(); + mockI18nService = mock(); + passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]); + usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]); + + mockPasswordGenerationService = mock(); + mockPasswordGenerationService.getOptions$.mockReturnValue( + passwordOptionsSubject.asObservable(), + ); + + mockUsernameGenerationService = mock(); + mockUsernameGenerationService.getOptions$.mockReturnValue( + usernameOptionsSubject.asObservable(), + ); + + const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent], + providers: [ + { + provide: DialogRef, + useValue: dialogRef, + }, + { + provide: DIALOG_DATA, + useValue: mockDialogData, + }, + { + provide: I18nService, + useValue: mockI18nService, + }, + { + provide: PlatformUtilsService, + useValue: mock(), + }, + { + provide: PasswordGenerationServiceAbstraction, + useValue: mockPasswordGenerationService, + }, + { + provide: UsernameGenerationServiceAbstraction, + useValue: mockUsernameGenerationService, + }, + { + provide: CipherFormGeneratorComponent, + useValue: { + passwordOptions$: passwordOptionsSubject.asObservable(), + usernameOptions$: usernameOptionsSubject.asObservable(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("initializes without errors", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("closes the dialog with 'canceled' result when close is called", () => { + const closeSpy = jest.spyOn(dialogRef, "close"); + + (component as any).close(); + + expect(closeSpy).toHaveBeenCalledWith({ + action: WebVaultGeneratorDialogAction.Canceled, + }); + }); + + it("closes the dialog with 'selected' result when selectValue is called", () => { + const closeSpy = jest.spyOn(dialogRef, "close"); + const generatedValue = "generated-value"; + component.onValueGenerated(generatedValue); + + (component as any).selectValue(); + + expect(closeSpy).toHaveBeenCalledWith({ + action: WebVaultGeneratorDialogAction.Selected, + generatedValue: generatedValue, + }); + }); + + it("updates generatedValue when onValueGenerated is called", () => { + const generatedValue = "new-generated-value"; + component.onValueGenerated(generatedValue); + + expect((component as any).generatedValue).toBe(generatedValue); + }); +}); diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts new file mode 100644 index 0000000000..03a41990c8 --- /dev/null +++ b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts @@ -0,0 +1,89 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, DialogService } from "@bitwarden/components"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; + +import { DialogModule } from "../../../../../../libs/components/src/dialog"; + +export interface WebVaultGeneratorDialogParams { + type: "password" | "username"; +} + +export interface WebVaultGeneratorDialogResult { + action: WebVaultGeneratorDialogAction; + generatedValue?: string; +} + +export enum WebVaultGeneratorDialogAction { + Selected = "selected", + Canceled = "canceled", +} + +@Component({ + selector: "web-vault-generator-dialog", + templateUrl: "./web-generator-dialog.component.html", + standalone: true, + imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule], +}) +export class WebVaultGeneratorDialogComponent { + protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); + protected selectButtonText = this.i18nService.t( + this.isPassword ? "useThisPassword" : "useThisUsername", + ); + + /** + * Whether the dialog is generating a password/passphrase. If false, it is generating a username. + * @protected + */ + protected get isPassword() { + return this.params.type === "password"; + } + + /** + * The currently generated value. + * @protected + */ + protected generatedValue: string = ""; + + constructor( + @Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + ) {} + + /** + * Close the dialog without selecting a value. + */ + protected close = () => { + this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled }); + }; + + /** + * Close the dialog and select the currently generated value. + */ + protected selectValue = () => { + this.dialogRef.close({ + action: WebVaultGeneratorDialogAction.Selected, + generatedValue: this.generatedValue, + }); + }; + + onValueGenerated(value: string) { + this.generatedValue = value; + } + + /** + * Opens the vault generator dialog. + */ + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + WebVaultGeneratorDialogComponent, + { + ...config, + }, + ); + } +} diff --git a/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts new file mode 100644 index 0000000000..898ac8dcb7 --- /dev/null +++ b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts @@ -0,0 +1,88 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component"; + +import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service"; + +describe("WebCipherFormGenerationService", () => { + let service: WebCipherFormGenerationService; + let dialogService: jest.Mocked; + let closed = of({}); + const close = jest.fn(); + const dialogRef = { + close, + get closed() { + return closed; + }, + } as unknown as DialogRef; + + beforeEach(() => { + dialogService = mock(); + + TestBed.configureTestingModule({ + providers: [ + WebCipherFormGenerationService, + { provide: DialogService, useValue: dialogService }, + ], + }); + + service = TestBed.inject(WebCipherFormGenerationService); + }); + + it("creates without error", () => { + expect(service).toBeTruthy(); + }); + + describe("generatePassword", () => { + it("opens the password generator dialog and returns the generated value", async () => { + const generatedValue = "generated-password"; + closed = of({ action: "generated", generatedValue }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generatePassword(); + + expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, { + data: { type: "password" }, + }); + expect(result).toBe(generatedValue); + }); + + it("returns null if the dialog is canceled", async () => { + closed = of({ action: "canceled" }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generatePassword(); + + expect(result).toBeNull(); + }); + }); + + describe("generateUsername", () => { + it("opens the username generator dialog and returns the generated value", async () => { + const generatedValue = "generated-username"; + closed = of({ action: "generated", generatedValue }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generateUsername(); + + expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, { + data: { type: "username" }, + }); + expect(result).toBe(generatedValue); + }); + + it("returns null if the dialog is canceled", async () => { + closed = of({ action: "canceled" }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generateUsername(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts new file mode 100644 index 0000000000..cfa0b28dbf --- /dev/null +++ b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts @@ -0,0 +1,40 @@ +import { inject, Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; +import { CipherFormGenerationService } from "@bitwarden/vault"; + +import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component"; + +@Injectable() +export class WebCipherFormGenerationService implements CipherFormGenerationService { + private dialogService = inject(DialogService); + + async generatePassword(): Promise { + const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, { + data: { type: "password" }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result == null || result.action === "canceled") { + return null; + } + + return result.generatedValue; + } + + async generateUsername(): Promise { + const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, { + data: { type: "username" }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result == null || result.action === "canceled") { + return null; + } + + return result.generatedValue; + } +}