diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index 02fa8076086..f2ca05c9336 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -22,6 +22,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { SshKeyPasswordPromptComponent } from "@bitwarden/importer/ui"; @@ -148,6 +149,16 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On ); } + /** + * Updates the cipher when an attachment is altered. + * Note: This only updates the `attachments` and `revisionDate` + * properties to ensure any in-progress edits are not lost. + */ + patchCipherAttachments(cipher: CipherView) { + this.cipher.attachments = cipher.attachments; + this.cipher.revisionDate = cipher.revisionDate; + } + async importSshKeyFromClipboard(password: string = "") { const key = await this.platformUtilsService.readFromClipboard(); const parsedKey = await ipc.platform.sshAgent.importKey(key, password); diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index da1f7bb3160..987c2691594 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -159,11 +159,6 @@ export class VaultComponent implements OnInit, OnDestroy { await this.vaultFilterComponent.reloadCollectionsAndFolders(this.activeFilter); await this.vaultFilterComponent.reloadOrganizations(); break; - case "refreshCiphers": - // 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 - this.vaultItemsComponent.refresh(); - break; case "modalShown": this.showingModal = true; break; @@ -535,9 +530,19 @@ export class VaultComponent implements OnInit, OnDestroy { let madeAttachmentChanges = false; // eslint-disable-next-line rxjs-angular/prefer-takeuntil - childComponent.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true)); + childComponent.onUploadedAttachment.subscribe((cipher) => { + madeAttachmentChanges = true; + // Update the edit component cipher with the updated cipher, + // which is needed because the revision date is updated when an attachment is altered + this.addEditComponent.patchCipherAttachments(cipher); + }); // eslint-disable-next-line rxjs-angular/prefer-takeuntil - childComponent.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true)); + childComponent.onDeletedAttachment.subscribe((cipher) => { + madeAttachmentChanges = true; + // Update the edit component cipher with the updated cipher, + // which is needed because the revision date is updated when an attachment is altered + this.addEditComponent.patchCipherAttachments(cipher); + }); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.modal.onClosed.subscribe(async () => { diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index a530fd0cc85..f4d9a9a73ee 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -43,6 +43,7 @@ import { DecryptionFailureDialogComponent, } from "@bitwarden/vault"; +import { CipherFormComponent } from "../../../../../../../libs/vault/src/cipher-form/components/cipher-form.component"; import { SharedModule } from "../../../shared/shared.module"; import { AttachmentDialogCloseResult, @@ -144,6 +145,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { @ViewChild("dialogContent") protected dialogContent: ElementRef; + @ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent; + /** * 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. @@ -432,6 +435,22 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { result.action === AttachmentDialogResult.Removed || result.action === AttachmentDialogResult.Uploaded ) { + const updatedCipher = await this.cipherService.get(this.formConfig.originalCipher?.id); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const updatedCipherView = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), + ); + + this.cipherFormComponent.patchCipher((currentCipher) => { + currentCipher.attachments = updatedCipherView.attachments; + currentCipher.revisionDate = updatedCipherView.revisionDate; + + return currentCipher; + }); + this._cipherModified = true; } }; diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index a3b635f151d..ec3dc43b447 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -16,6 +16,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -26,7 +27,7 @@ import { KeyService } from "@bitwarden/key-management"; export class AttachmentsComponent implements OnInit { @Input() cipherId: string; @Input() viewOnly: boolean; - @Output() onUploadedAttachment = new EventEmitter(); + @Output() onUploadedAttachment = new EventEmitter(); @Output() onDeletedAttachment = new EventEmitter(); @Output() onReuploadedAttachment = new EventEmitter(); @@ -34,7 +35,7 @@ export class AttachmentsComponent implements OnInit { cipherDomain: Cipher; canAccessAttachments: boolean; formPromise: Promise; - deletePromises: { [id: string]: Promise } = {}; + deletePromises: { [id: string]: Promise } = {}; reuploadPromises: { [id: string]: Promise } = {}; emergencyAccessId?: string = null; protected componentName = ""; @@ -96,7 +97,7 @@ export class AttachmentsComponent implements OnInit { title: null, message: this.i18nService.t("attachmentSaved"), }); - this.onUploadedAttachment.emit(); + this.onUploadedAttachment.emit(this.cipher); } catch (e) { this.logService.error(e); } @@ -125,7 +126,16 @@ export class AttachmentsComponent implements OnInit { try { this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id); - await this.deletePromises[attachment.id]; + const updatedCipher = await this.deletePromises[attachment.id]; + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const cipher = new Cipher(updatedCipher); + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + this.toastService.showToast({ variant: "success", title: null, @@ -140,7 +150,7 @@ export class AttachmentsComponent implements OnInit { } this.deletePromises[attachment.id] = null; - this.onDeletedAttachment.emit(); + this.onDeletedAttachment.emit(this.cipher); } async download(attachment: AttachmentView) { diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 4ef00e90063..f093aeb1330 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -1,7 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { BehaviorSubject, firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -40,7 +41,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy { constructor( protected searchService: SearchService, protected cipherService: CipherService, - ) {} + ) { + this.cipherService.cipherViews$.pipe(takeUntilDestroyed()).subscribe((ciphers) => { + void this.doSearch(ciphers); + this.loaded = true; + }); + } ngOnInit(): void { this._searchText$ @@ -117,7 +123,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; protected async doSearch(indexedCiphers?: CipherView[]) { - indexedCiphers = indexedCiphers ?? (await this.cipherService.getAllDecrypted()); + indexedCiphers = indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$)); const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$); if (failedCiphers != null && failedCiphers.length > 0) { diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 18caa875e03..abbedf13078 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -11,7 +11,7 @@ import { OnInit, Output, } from "@angular/core"; -import { firstValueFrom, map, Observable } from "rxjs"; +import { filter, firstValueFrom, map, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -144,11 +144,15 @@ export class ViewComponent implements OnDestroy, OnInit { async load() { this.cleanUp(); - const cipher = await this.cipherService.get(this.cipherId); const activeUserId = await firstValueFrom(this.activeUserId$); - this.cipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + // Grab individual cipher from `cipherViews$` for the most up-to-date information + this.cipher = await firstValueFrom( + this.cipherService.cipherViews$.pipe( + map((ciphers) => ciphers.find((c) => c.id === this.cipherId)), + filter((cipher) => !!cipher), + ), ); + this.canAccessPremium = await firstValueFrom( this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), ); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 03ea969c7bc..ad59ad0837a 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -702,7 +702,7 @@ export class ApiService implements ApiServiceAbstraction { } deleteCipherAttachment(id: string, attachmentId: string): Promise { - return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, false); + return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true); } deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise { diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2e34f0ac601..0672ae29e91 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -154,8 +154,8 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise; deleteWithServer: (id: string, asAdmin?: boolean) => Promise; deleteManyWithServer: (ids: string[], asAdmin?: boolean) => Promise; - deleteAttachment: (id: string, attachmentId: string) => Promise; - deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise; + deleteAttachment: (id: string, revisionDate: string, attachmentId: string) => Promise; + deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise; sortCiphersByLastUsed: (a: CipherView, b: CipherView) => number; sortCiphersByLastUsedThenName: (a: CipherView, b: CipherView) => number; getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index b1cdf72e08e..18295453d9a 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1078,7 +1078,11 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(ids); } - async deleteAttachment(id: string, attachmentId: string): Promise { + async deleteAttachment( + id: string, + revisionDate: string, + attachmentId: string, + ): Promise { let ciphers = await firstValueFrom(this.ciphers$); const cipherId = id as CipherId; // eslint-disable-next-line @@ -1092,6 +1096,10 @@ export class CipherService implements CipherServiceAbstraction { } } + // Deleting the cipher updates the revision date on the server, + // Update the stored `revisionDate` to match + ciphers[cipherId].revisionDate = revisionDate; + await this.clearCache(); await this.encryptedCiphersState.update(() => { if (ciphers == null) { @@ -1099,15 +1107,20 @@ export class CipherService implements CipherServiceAbstraction { } return ciphers; }); + + return ciphers[cipherId]; } - async deleteAttachmentWithServer(id: string, attachmentId: string): Promise { + async deleteAttachmentWithServer(id: string, attachmentId: string): Promise { + let cipherResponse = null; try { - await this.apiService.deleteCipherAttachment(id, attachmentId); + cipherResponse = await this.apiService.deleteCipherAttachment(id, attachmentId); } catch (e) { return Promise.reject((e as ErrorResponse).getSingleMessage()); } - await this.deleteAttachment(id, attachmentId); + const cipherData = CipherData.fromJSON(cipherResponse?.cipher); + + return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId); } sortCiphersByLastUsed(a: CipherView, b: CipherView): number { diff --git a/libs/vault/src/cipher-form/index.ts b/libs/vault/src/cipher-form/index.ts index 8cb779a8ec3..75e805b3be9 100644 --- a/libs/vault/src/cipher-form/index.ts +++ b/libs/vault/src/cipher-form/index.ts @@ -9,3 +9,4 @@ export { TotpCaptureService } from "./abstractions/totp-capture.service"; export { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service"; export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service"; export { CipherFormGeneratorComponent } from "./components/cipher-generator/cipher-form-generator.component"; +export { CipherFormContainer } from "../cipher-form/cipher-form-container";