diff --git a/apps/browser/package.json b/apps/browser/package.json index fff378c72b9..9d878694594 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.9.2", + "version": "2024.10.0", "scripts": { "build": "cross-env MANIFEST_VERSION=3 webpack", "build:mv2": "webpack", diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 62f9bf05dad..fd513f8792d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -283,7 +283,7 @@ export default class MainBackground { folderService: InternalFolderServiceAbstraction; userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction; collectionService: CollectionServiceAbstraction; - vaultTimeoutService: VaultTimeoutService; + vaultTimeoutService?: VaultTimeoutService; vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction; passwordGenerationService: PasswordGenerationServiceAbstraction; syncService: SyncService; @@ -842,24 +842,26 @@ export default class MainBackground { this.vaultSettingsService = new VaultSettingsService(this.stateProvider); - this.vaultTimeoutService = new VaultTimeoutService( - this.accountService, - this.masterPasswordService, - this.cipherService, - this.folderService, - this.collectionService, - this.platformUtilsService, - this.messagingService, - this.searchService, - this.stateService, - this.authService, - this.vaultTimeoutSettingsService, - this.stateEventRunnerService, - this.taskSchedulerService, - this.logService, - lockedCallback, - logoutCallback, - ); + if (!this.popupOnlyContext) { + this.vaultTimeoutService = new VaultTimeoutService( + this.accountService, + this.masterPasswordService, + this.cipherService, + this.folderService, + this.collectionService, + this.platformUtilsService, + this.messagingService, + this.searchService, + this.stateService, + this.authService, + this.vaultTimeoutSettingsService, + this.stateEventRunnerService, + this.taskSchedulerService, + this.logService, + lockedCallback, + logoutCallback, + ); + } this.containerService = new ContainerService(this.cryptoService, this.encryptService); this.sendStateProvider = new SendStateProvider(this.stateProvider); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 35692dd5674..598c6ff0286 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.9.2", + "version": "2024.10.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 7bd40691768..5413ee5b63f 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.9.2", + "version": "2024.10.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 8463bbe6e9f..22b3ffbe6de 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -56,7 +56,7 @@ export class PopupTabNavigationComponent { .pipe( filter((policyAppliesToActiveUser) => policyAppliesToActiveUser), switchMap(() => this.sendService.sends$), - map((sends) => sends.length > 1), + map((sends) => sends.length > 0), takeUntilDestroyed(), ) .subscribe((hasSends) => { diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html index 2ccfeaf3459..69322b08c8a 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html @@ -7,14 +7,15 @@ - {{ cipher.name }} - + + + + + + + + + + +
+ +
+
+ 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 00000000000..4969ea2b16a --- /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/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 20942da3fe8..4302edcb4ec 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -106,12 +106,7 @@ - @@ -119,26 +114,6 @@ {{ "clone" | i18n }} - - - - - -