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 new file mode 100644 index 0000000000..521665496a --- /dev/null +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -0,0 +1,79 @@ + + + {{ 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 new file mode 100644 index 0000000000..4969ea2b16 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -0,0 +1,436 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom, Subject } from "rxjs"; +import { map } from "rxjs/operators"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogService, + ItemModule, + ToastService, +} from "@bitwarden/components"; +import { + CipherAttachmentsComponent, + CipherFormConfig, + CipherFormGenerationService, + CipherFormModule, + CipherViewComponent, +} from "@bitwarden/vault"; + +import { SharedModule } from "../../../shared/shared.module"; +import { + AttachmentDialogCloseResult, + AttachmentDialogResult, + AttachmentsV2Component, +} from "../../individual-vault/attachments-v2.component"; +import { WebCipherFormGenerationService } from "../../services/web-cipher-form-generation.service"; +import { WebVaultPremiumUpgradePromptService } from "../../services/web-premium-upgrade-prompt.service"; +import { WebViewPasswordHistoryService } from "../../services/web-view-password-history.service"; + +export type VaultItemDialogMode = "view" | "form"; + +export interface VaultItemDialogParams { + /** + * The mode of the dialog. + * - `view` is for viewing an existing cipher. + * - `form` is for editing or creating a new cipher. + */ + mode: VaultItemDialogMode; + + /** + * The configuration object for the dialog and form. + */ + formConfig: CipherFormConfig; + + /** + * If true, the "edit" button will be disabled in the dialog. + */ + disableForm?: boolean; +} + +export enum VaultItemDialogResult { + /** + * A cipher was saved (created or updated). + */ + Saved = "saved", + + /** + * A cipher was deleted. + */ + Deleted = "deleted", + + /** + * The dialog was closed to navigate the user the premium upgrade page. + */ + PremiumUpgrade = "premiumUpgrade", +} + +@Component({ + selector: "app-vault-item-dialog", + templateUrl: "vault-item-dialog.component.html", + standalone: true, + imports: [ + ButtonModule, + CipherViewComponent, + DialogModule, + CommonModule, + SharedModule, + CipherFormModule, + CipherAttachmentsComponent, + AsyncActionsModule, + ItemModule, + ], + providers: [ + { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, + { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, + { provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }, + ], +}) +export class VaultItemDialogComponent implements OnInit, OnDestroy { + /** + * Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes. + * @protected + */ + @ViewChild("dialogContent") + protected dialogContent: ElementRef; + + /** + * Tracks if the cipher was ever modified while the dialog was open. Used to ensure the dialog emits the correct result + * in case of closing with the X button or ESC key. + * @private + */ + private _cipherModified: boolean = false; + + /** + * The original mode of the form when the dialog is first opened. + * Used to determine if the form should switch to edit mode after successfully creating a new cipher. + * @private + */ + private _originalFormMode = this.params.formConfig.mode; + + /** + * Subject to emit when the form is ready to be displayed. + * @private + */ + private _formReadySubject = new Subject(); + + /** + * Tracks if the dialog is performing the initial load. Used to display a spinner while loading. + * @private + */ + protected performingInitialLoad: boolean = true; + + /** + * The title of the dialog. Updates based on the dialog mode and cipher type. + * @protected + */ + protected title: string; + + /** + * The current cipher being viewed. Undefined if creating a new cipher. + * @protected + */ + protected cipher?: CipherView; + + /** + * The organization the current cipher belongs to. Undefined if creating a new cipher. + * @protected + */ + protected organization?: Organization; + + /** + * The collections the current cipher is assigned to. Undefined if creating a new cipher. + * @protected + */ + protected collections?: CollectionView[]; + + /** + * Flag to indicate if the user has access to attachments via a premium subscription. + * @protected + */ + protected canAccessAttachments$ = this.billingAccountProfileStateService.hasPremiumFromAnySource$; + + protected get loadingForm() { + return this.loadForm && !this.formReady; + } + + protected get disableEdit() { + return this.params.disableForm; + } + + protected get canDelete() { + return this.cipher?.edit ?? false; + } + + protected get showCipherView() { + return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); + } + + /** + * Flag to initialize/attach the form component. + */ + protected loadForm = this.params.mode === "form"; + + /** + * Flag to indicate the form is ready to be displayed. + */ + protected formReady = false; + + protected formConfig: CipherFormConfig = this.params.formConfig; + + constructor( + @Inject(DIALOG_DATA) protected params: VaultItemDialogParams, + private dialogRef: DialogRef, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private messagingService: MessagingService, + private logService: LogService, + private cipherService: CipherService, + private accountService: AccountService, + private router: Router, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) { + this.updateTitle(); + } + + async ngOnInit() { + this.cipher = await this.getDecryptedCipherView(this.formConfig); + + if (this.cipher) { + this.collections = this.formConfig.collections.filter((c) => + this.cipher.collectionIds?.includes(c.id), + ); + this.organization = this.formConfig.organizations.find( + (o) => o.id === this.cipher.organizationId, + ); + } + + this.performingInitialLoad = false; + } + + ngOnDestroy() { + // If the cipher was modified, be sure we emit the saved result in case the dialog was closed with the X button or ESC key. + if (this._cipherModified) { + this.dialogRef.close(VaultItemDialogResult.Saved); + } + } + + /** + * Called by the CipherFormComponent when the cipher is saved successfully. + * @param cipherView - The newly saved cipher. + */ + protected async onCipherSaved(cipherView: CipherView) { + // We successfully saved the cipher, update the dialog state and switch to view mode. + this.cipher = cipherView; + this.collections = this.formConfig.collections.filter((c) => + cipherView.collectionIds?.includes(c.id), + ); + + // If the cipher was newly created (via add/clone), switch the form to edit for subsequent edits. + if (this._originalFormMode === "add" || this._originalFormMode === "clone") { + this.formConfig.mode = "edit"; + } + this.formConfig.originalCipher = await this.cipherService.get(cipherView.id); + this._cipherModified = true; + await this.changeMode("view"); + } + + /** + * Called by the CipherFormComponent when the form is ready to be displayed. + */ + protected onFormReady() { + this.formReady = true; + this._formReadySubject.next(); + } + + delete = async () => { + if (!this.cipher) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", + }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.deleteCipher(); + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("success"), + message: this.i18nService.t( + this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem", + ), + }); + this.messagingService.send( + this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher", + ); + } catch (e) { + this.logService.error(e); + } + this._cipherModified = false; + this.dialogRef.close(VaultItemDialogResult.Deleted); + }; + + openAttachmentsDialog = async () => { + const dialogRef = this.dialogService.open( + AttachmentsV2Component, + { + data: { + cipherId: this.formConfig.originalCipher?.id as CipherId, + }, + }, + ); + + const result = await firstValueFrom(dialogRef.closed); + + if ( + result.action === AttachmentDialogResult.Removed || + result.action === AttachmentDialogResult.Uploaded + ) { + this._cipherModified = true; + } + }; + + switchToEdit = async () => { + if (!this.cipher) { + return; + } + await this.changeMode("form"); + }; + + cancel = async () => { + // We're in View mode, or we don't have a cipher, close the dialog. + if (this.params.mode === "view" || this.cipher == null) { + this.dialogRef.close(this._cipherModified ? VaultItemDialogResult.Saved : undefined); + return; + } + + // We're in Form mode, and we have a cipher, switch back to View mode. + await this.changeMode("view"); + }; + + private async getDecryptedCipherView(config: CipherFormConfig) { + if (config.originalCipher == null) { + return; + } + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + return await config.originalCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(config.originalCipher, activeUserId), + ); + } + + private updateTitle() { + let partOne: string; + + if (this.params.mode === "view") { + partOne = "viewItemType"; + } else if (this.formConfig.mode === "edit" || this.formConfig.mode === "partial-edit") { + partOne = "editItemHeader"; + } else { + partOne = "newItemHeader"; + } + + const type = this.cipher?.type ?? this.formConfig.cipherType ?? CipherType.Login; + + switch (type) { + case CipherType.Login: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase()); + break; + case CipherType.Card: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase()); + break; + case CipherType.Identity: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase()); + break; + case CipherType.SecureNote: + this.title = this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase()); + break; + } + } + + /** + * Changes the mode of the dialog. When switching to Form mode, the form is initialized first then displayed once ready. + * @param mode + * @private + */ + private async changeMode(mode: VaultItemDialogMode) { + this.formReady = false; + + if (mode == "form") { + this.loadForm = true; + // Wait for the formReadySubject to emit before continuing. + // This helps prevent flashing an empty dialog while the form is initializing. + await firstValueFrom(this._formReadySubject); + } else { + this.loadForm = false; + } + + this.params.mode = mode; + this.updateTitle(); + // Scroll to the top of the dialog content when switching modes. + this.dialogContent.nativeElement.parentElement.scrollTop = 0; + + // Update the URL query params to reflect the new mode. + await this.router.navigate([], { + queryParams: { + action: mode === "form" ? "edit" : "view", + itemId: this.cipher?.id, + }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + /** + * Helper method to delete cipher. + */ + private async deleteCipher(): Promise { + const asAdmin = this.organization?.canEditAllCiphers; + if (this.cipher.isDeleted) { + await this.cipherService.deleteWithServer(this.cipher.id, asAdmin); + } else { + await this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin); + } + } + + /** + * Opens the VaultItemDialog. + * @param dialogService + * @param params + */ + static open(dialogService: DialogService, params: VaultItemDialogParams) { + return dialogService.open( + VaultItemDialogComponent, + { + data: params, + }, + ); + } +} diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html similarity index 100% rename from libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html rename to apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts similarity index 91% rename from libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts rename to apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts index 844f15a47a..653f553313 100644 --- a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { DIALOG_DATA, DialogRef } 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"; @@ -6,15 +6,16 @@ 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 { + PasswordGenerationServiceAbstraction, + UsernameGenerationServiceAbstraction, +} from "@bitwarden/generator-legacy"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { + WebVaultGeneratorDialogAction, WebVaultGeneratorDialogComponent, WebVaultGeneratorDialogParams, - WebVaultGeneratorDialogAction, } from "./web-generator-dialog.component"; describe("WebVaultGeneratorDialogComponent", () => { diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts similarity index 94% rename from libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts rename to apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts index 03a41990c8..91ab7ba7cc 100644 --- a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts @@ -3,11 +3,9 @@ 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 { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; -import { DialogModule } from "../../../../../../libs/components/src/dialog"; - export interface WebVaultGeneratorDialogParams { type: "password" | "username"; } 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 index 64935c8af3..85faac0c08 100644 --- 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 @@ -17,9 +17,8 @@ import { 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 { WebCipherFormGenerationService } from "../services/web-cipher-form-generation.service"; import { AttachmentsV2Component } from "./attachments-v2.component"; @@ -48,13 +47,13 @@ export interface AddEditCipherDialogCloseResult { /** * Component for viewing a cipher, presented in a dialog. + * @deprecated Use the VaultItemDialogComponent instead. */ @Component({ selector: "app-vault-add-edit-v2", templateUrl: "add-edit-v2.component.html", standalone: true, imports: [ - CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, 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 index 532a0224be..256082c298 100644 --- a/apps/web/src/app/vault/individual-vault/attachments-v2.component.html +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.html @@ -1,4 +1,4 @@ - + {{ "attachments" | i18n }} 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 4c4919cb05..734ab5acf1 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -1,3 +1,4 @@ +import { DialogRef } from "@angular/cdk/dialog"; import { ChangeDetectorRef, Component, @@ -63,6 +64,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { DialogService, Icons, ToastService } from "@bitwarden/components"; import { + CipherFormConfig, CollectionAssignmentResult, DefaultCipherFormConfigService, PasswordRepromptService, @@ -75,16 +77,16 @@ import { CollectionDialogTabType, openCollectionDialog, } from "../components/collection-dialog"; +import { + VaultItemDialogComponent, + VaultItemDialogMode, + VaultItemDialogResult, +} from "../components/vault-item-dialog/vault-item-dialog.component"; import { VaultItem } from "../components/vault-items/vault-item"; 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, @@ -116,11 +118,6 @@ import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/v import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component"; -import { - openViewCipherDialog, - ViewCipherDialogCloseResult, - ViewCipherDialogResult, -} from "./view.component"; const BroadcasterSubscriptionId = "VaultComponent"; const SearchTextDebounceInterval = 200; @@ -179,6 +176,8 @@ export class VaultComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); private extensionRefreshEnabled: boolean; + private vaultItemDialogRef?: DialogRef | undefined; + constructor( private syncService: SyncService, private route: ActivatedRoute, @@ -352,12 +351,20 @@ export class VaultComponent implements OnInit, OnDestroy { firstSetup$ .pipe( switchMap(() => this.route.queryParams), + // Only process the queryParams if the dialog is not open (only when extension refresh is enabled) + filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); if (cipherId) { if (await this.cipherService.get(cipherId)) { - if (params.action === "view") { + let action = params.action; + // Default to "view" if extension refresh is enabled + if (action == null && this.extensionRefreshEnabled) { + action = "view"; + } + + if (action === "view") { await this.viewCipherById(cipherId); } else { await this.editCipherId(cipherId); @@ -526,7 +533,7 @@ export class VaultComponent implements OnInit, OnDestroy { */ async editCipherAttachments(cipher: CipherView) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { - this.go({ cipherId: null, itemId: null }); + await this.go({ cipherId: null, itemId: null }); return; } @@ -590,6 +597,29 @@ export class VaultComponent implements OnInit, OnDestroy { }); } + /** + * Open the combined view / edit dialog for a cipher. + * @param mode - Starting mode of the dialog. + * @param formConfig - Configuration for the form when editing/adding a cipher. + */ + async openVaultItemDialog(mode: VaultItemDialogMode, formConfig: CipherFormConfig) { + this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { + mode, + formConfig, + }); + + const result = await lastValueFrom(this.vaultItemDialogRef.closed); + this.vaultItemDialogRef = undefined; + + // If the dialog was closed by deleting the cipher, refresh the vault. + if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { + this.refresh(); + } + + // Clear the query params when the dialog closes + await this.go({ cipherId: null, itemId: null, action: null }); + } + async addCipher(cipherType?: CipherType) { if (this.extensionRefreshEnabled) { return this.addCipherV2(cipherType); @@ -643,23 +673,7 @@ export class VaultComponent implements OnInit, OnDestroy { 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 }); + await this.openVaultItemDialog("form", cipherFormConfig); } async editCipher(cipher: CipherView, cloneMode?: boolean) { @@ -675,7 +689,7 @@ export class VaultComponent implements OnInit, OnDestroy { !(await this.passwordRepromptService.showPasswordPrompt()) ) { // didn't pass password prompt, so don't open add / edit modal - this.go({ cipherId: null, itemId: null, action: null }); + await this.go({ cipherId: null, itemId: null, action: null }); return; } @@ -707,14 +721,14 @@ export class VaultComponent implements OnInit, OnDestroy { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises modal.onClosedPromise().then(() => { - this.go({ cipherId: null, itemId: null, action: null }); + void this.go({ cipherId: null, itemId: null, action: null }); }); return childComponent; } /** - * Edit a cipher using the new AddEditCipherDialogV2 component. + * Edit a cipher using the new VaultItemDialog. * * @param cipher * @param cloneMode @@ -726,31 +740,7 @@ export class VaultComponent implements OnInit, OnDestroy { 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 }); + await this.openVaultItemDialog("form", cipherFormConfig); } /** @@ -777,39 +767,17 @@ export class VaultComponent implements OnInit, OnDestroy { !(await this.passwordRepromptService.showPasswordPrompt()) ) { // Didn't pass password prompt, so don't open add / edit modal. - this.go({ cipherId: null, itemId: null }); + await this.go({ cipherId: null, itemId: null, action: null }); return; } - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - // Decrypt the cipher. - const cipherView = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + cipher.edit ? "edit" : "partial-edit", + cipher.id as CipherId, + cipher.type, ); - // Open the dialog. - const dialogRef = openViewCipherDialog(this.dialogService, { - data: { cipher: cipherView }, - }); - - // Wait for the dialog to close. - const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); - - // If the dialog was closed by clicking the edit button, navigate to open the edit dialog. - if (result?.action === ViewCipherDialogResult.Edited) { - this.go({ itemId: cipherView.id, action: "edit" }); - return; - } - - // If the dialog was closed by deleting the cipher, refresh the vault. - if (result?.action === ViewCipherDialogResult.Deleted) { - this.refresh(); - } - - // Clear the query params when the view dialog closes - this.go({ cipherId: null, itemId: null, action: null }); + await this.openVaultItemDialog("view", cipherFormConfig); } async addCollection() { @@ -958,7 +926,10 @@ export class VaultComponent implements OnInit, OnDestroy { } const component = await this.editCipher(cipher, true); - component.cloneMode = true; + + if (component != null) { + component.cloneMode = true; + } } async restore(c: CipherView): Promise { @@ -1220,7 +1191,7 @@ export class VaultComponent implements OnInit, OnDestroy { return organization.canEditAllCiphers; } - private go(queryParams: any = null) { + private async go(queryParams: any = null) { if (queryParams == null) { queryParams = { favorites: this.activeFilter.isFavorites || null, @@ -1231,7 +1202,7 @@ export class VaultComponent implements OnInit, OnDestroy { }; } - void this.router.navigate([], { + await this.router.navigate([], { relativeTo: this.route, queryParams: queryParams, queryParamsHandling: "merge", 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 ead52d805a..4841a18613 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, Inject, OnInit, EventEmitter } from "@angular/core"; +import { Component, EventEmitter, Inject, OnInit } from "@angular/core"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -52,6 +52,7 @@ export interface ViewCipherDialogCloseResult { /** * Component for viewing a cipher, presented in a dialog. + * @deprecated Use the VaultItemDialogComponent instead. */ @Component({ selector: "app-vault-view", diff --git a/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts b/apps/web/src/app/vault/services/web-cipher-form-generation.service.spec.ts similarity index 100% rename from libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts rename to apps/web/src/app/vault/services/web-cipher-form-generation.service.spec.ts diff --git a/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts b/apps/web/src/app/vault/services/web-cipher-form-generation.service.ts similarity index 100% rename from libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts rename to apps/web/src/app/vault/services/web-cipher-form-generation.service.ts diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts index 6c68dae707..57d08595e1 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -1,15 +1,12 @@ import { DialogRef } from "@angular/cdk/dialog"; import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; -import { of, lastValueFrom } from "rxjs"; +import { lastValueFrom, of } from "rxjs"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; -import { - ViewCipherDialogCloseResult, - ViewCipherDialogResult, -} from "../individual-vault/view.component"; +import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component"; import { WebVaultPremiumUpgradePromptService } from "./web-premium-upgrade-prompt.service"; @@ -17,7 +14,7 @@ describe("WebVaultPremiumUpgradePromptService", () => { let service: WebVaultPremiumUpgradePromptService; let dialogServiceMock: jest.Mocked; let routerMock: jest.Mocked; - let dialogRefMock: jest.Mocked>; + let dialogRefMock: jest.Mocked>; beforeEach(() => { dialogServiceMock = { @@ -30,7 +27,7 @@ describe("WebVaultPremiumUpgradePromptService", () => { dialogRefMock = { close: jest.fn(), - } as unknown as jest.Mocked>; + } as unknown as jest.Mocked>; TestBed.configureTestingModule({ providers: [ @@ -62,9 +59,7 @@ describe("WebVaultPremiumUpgradePromptService", () => { "billing", "subscription", ]); - expect(dialogRefMock.close).toHaveBeenCalledWith({ - action: ViewCipherDialogResult.PremiumUpgrade, - }); + expect(dialogRefMock.close).toHaveBeenCalledWith(VaultItemDialogResult.PremiumUpgrade); }); it("prompts for premium upgrade and navigates to premium subscription if organizationId is not provided", async () => { @@ -79,9 +74,7 @@ describe("WebVaultPremiumUpgradePromptService", () => { type: "success", }); expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]); - expect(dialogRefMock.close).toHaveBeenCalledWith({ - action: ViewCipherDialogResult.PremiumUpgrade, - }); + expect(dialogRefMock.close).toHaveBeenCalledWith(VaultItemDialogResult.PremiumUpgrade); }); it("does not navigate or close dialog if upgrade is no action is taken", async () => { diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts index 8f9c8c0bd7..ec15937b05 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -6,10 +6,7 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService } from "@bitwarden/components"; -import { - ViewCipherDialogCloseResult, - ViewCipherDialogResult, -} from "../individual-vault/view.component"; +import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component"; /** * This service is used to prompt the user to upgrade to premium. @@ -19,7 +16,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt constructor( private dialogService: DialogService, private router: Router, - private dialog: DialogRef, + private dialog: DialogRef, ) {} /** @@ -51,7 +48,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt } if (upgradeConfirmed) { - this.dialog.close({ action: ViewCipherDialogResult.PremiumUpgrade }); + this.dialog.close(VaultItemDialogResult.PremiumUpgrade); } } } diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 2a65218e82..4df6aa67ea 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -1,6 +1,7 @@ import { NgIf } from "@angular/common"; import { AfterViewInit, + ChangeDetectorRef, Component, DestroyRef, EventEmitter, @@ -14,6 +15,7 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { Subject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; @@ -101,6 +103,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci */ @Output() cipherSaved = new EventEmitter(); + private formReadySubject = new Subject(); + + @Output() formReady = this.formReadySubject.asObservable(); + /** * The original cipher being edited or cloned. Null for add mode. */ @@ -173,9 +179,13 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci async init() { this.loading = true; + + // Force change detection so that all child components are destroyed and re-created + this.changeDetectorRef.detectChanges(); + this.updatedCipherView = new CipherView(); this.originalCipherView = null; - this.cipherForm.reset(); + this.cipherForm = this.formBuilder.group({}); if (this.config == null) { return; @@ -207,6 +217,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci } this.loading = false; + this.formReadySubject.next(); } constructor( @@ -214,6 +225,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci private addEditFormService: CipherFormService, private toastService: ToastService, private i18nService: I18nService, + private changeDetectorRef: ChangeDetectorRef, ) {} /** diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index e28f7f2a2b..c73ad14940 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { Component, Input, OnChanges, OnDestroy } from "@angular/core"; import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -44,7 +44,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide AutofillOptionsViewComponent, ], }) -export class CipherViewComponent implements OnInit, OnDestroy { +export class CipherViewComponent implements OnChanges, OnDestroy { @Input({ required: true }) cipher: CipherView; /** @@ -63,7 +63,11 @@ export class CipherViewComponent implements OnInit, OnDestroy { private folderService: FolderService, ) {} - async ngOnInit() { + async ngOnChanges() { + if (this.cipher == null) { + return; + } + await this.loadCipherData(); this.cardIsExpired = isCardExpired(this.cipher.card);