From 404d925f845eed52991053438fa839eabaac9526 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:39:22 -0800 Subject: [PATCH] [PM-24560] - Add Archive UI Element to View and Edit Item Cards (#16954) * finalize new UI elements for archive/unarchive * add tests * add missing service * add tests * updates to edit and view pages * use structureClone * fix lint * fix typo * clean up return types * fixes to archive UI * fix tests * use @if and userId$ --- apps/browser/src/_locales/en/messages.json | 3 + .../add-edit/add-edit-v2.component.html | 38 +++- .../add-edit/add-edit-v2.component.spec.ts | 139 +++++++++++++- .../add-edit/add-edit-v2.component.ts | 68 ++++++- .../vault-v2/view-v2/view-v2.component.html | 70 ++++--- .../view-v2/view-v2.component.spec.ts | 171 ++++++++++++++++- .../vault-v2/view-v2/view-v2.component.ts | 26 +++ .../vault-item-dialog.component.html | 25 ++- .../vault-item-dialog.component.spec.ts | 174 +++++++++++++++++- .../vault-item-dialog.component.ts | 110 ++++++++--- apps/web/src/locales/en/messages.json | 3 + .../abstractions/cipher-archive.service.ts | 6 +- .../src/vault/models/view/cipher.view.ts | 4 + .../default-cipher-archive.service.spec.ts | 4 + .../default-cipher-archive.service.ts | 19 +- .../archive-cipher-utilities.service.spec.ts | 6 +- .../archive-cipher-utilities.service.ts | 80 ++++---- 17 files changed, 824 insertions(+), 122 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1613373bd62..d3a393ecc37 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 8f184c6a0c1..7230c565a48 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -31,14 +31,34 @@ {{ "cancel" | i18n }} - + + @if (isEditMode) { + @if ((archiveFlagEnabled$ | async) && isCipherArchived) { + + } + @if ((userCanArchive$ | async) && canCipherBeArchived) { + + } + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index f2c9d470816..4ffe44133d7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -1,7 +1,8 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -12,13 +13,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { DialogService } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, CipherFormConfig, CipherFormConfigService, CipherFormMode, @@ -45,15 +49,15 @@ describe("AddEditV2Component", () => { let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; - const buildConfig = jest.fn((mode: CipherFormMode) => - Promise.resolve({ ...buildConfigResponse, mode }), - ); + const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode })); const queryParams$ = new BehaviorSubject({}); const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); const setHistory = jest.fn(); const collect = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + const cipherArchiveService = mock(); beforeEach(async () => { buildConfig.mockClear(); @@ -61,6 +65,10 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); collect.mockClear(); + openSimpleDialog.mockClear(); + + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock({ @@ -83,10 +91,21 @@ describe("AddEditV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, ], }) .overrideProvider(CipherFormConfigService, { @@ -94,6 +113,11 @@ describe("AddEditV2Component", () => { buildConfig, }, }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) .compileComponents(); fixture = TestBed.createComponent(AddEditV2Component); @@ -356,6 +380,111 @@ describe("AddEditV2Component", () => { }); }); + describe("archive", () => { + it("calls archiveCipherUtilsService service to archive the cipher", async () => { + buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await fixture.whenStable(); + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archiveCipherUtilsService service to unarchive the cipher", async () => { + buildConfigResponse.originalCipher = { + id: "222-333-444-5555", + archivedDate: new Date(), + edit: true, + } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + ); + }); + }); + + describe("archive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { archivedDate: undefined, edit: true } as Cipher; + }); + + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { edit: true } as Cipher; + }); + + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + describe("delete", () => { it("dialogService openSimpleDialog called when deleteBtn is hit", async () => { const dialogSpy = jest diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 22aad854dd0..8704694fd53 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; @@ -31,6 +32,8 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, + CipherFormComponent, CipherFormConfig, CipherFormConfigService, CipherFormGenerationService, @@ -159,6 +162,7 @@ export type AddEditQueryParams = Partial>; ], }) export class AddEditV2Component implements OnInit, OnDestroy { + readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; @@ -171,6 +175,18 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.config?.originalCipher?.id as CipherId; } + get cipher(): CipherView { + return new CipherView(this.config?.originalCipher); + } + + get canCipherBeArchived(): boolean { + return this.cipher?.canBeArchived; + } + + get isCipherArchived(): boolean { + return this.cipher?.isArchived; + } + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData: Fido2SessionData; @@ -182,6 +198,16 @@ export class AddEditV2Component implements OnInit, OnDestroy { return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem); } + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + + /** + * Flag to indicate if the user can archive items. + * @protected + */ + protected userCanArchive$ = this.accountService.activeAccount$.pipe( + switchMap((account) => this.archiveService.userCanArchive$(account.id)), + ); + constructor( private route: ActivatedRoute, private i18nService: I18nService, @@ -196,6 +222,8 @@ export class AddEditV2Component implements OnInit, OnDestroy { private dialogService: DialogService, protected cipherAuthorizationService: CipherAuthorizationService, private accountService: AccountService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -322,6 +350,10 @@ export class AddEditV2Component implements OnInit, OnDestroy { await BrowserApi.sendMessage("addEditCipherSubmitted"); } + get isEditMode(): boolean { + return ["edit", "partial-edit"].includes(this.config?.mode); + } + subscribeToParams(): void { this.route.queryParams .pipe( @@ -430,6 +462,40 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.i18nService.t(translation[type]); } + /** + * Update the cipher in the form after archiving/unarchiving. + * @param revisionDate The new revision date. + * @param archivedDate The new archived date (null if unarchived). + **/ + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipherFormComponent().patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + new Date(cipherResponse.archivedDate), + ); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + }; + delete = async () => { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteItem" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 9b8380a4214..d2a4aaab3f0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -3,37 +3,47 @@ - + @if (cipher) { + + } - - {{ "edit" | i18n }} - - - - {{ "restore" | i18n }} - - - + @if (!cipher.isDeleted) { + + {{ "edit" | i18n }} + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + {{ "restore" | i18n }} + + } + + @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + + } + @if ((userCanArchive$ | async) && cipher.canBeArchived) { + + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 3d4fdb2e9f9..9c536a7e85a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -1,9 +1,13 @@ -import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of, Subject } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, @@ -11,20 +15,32 @@ import { COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; +import { + ArchiveCipherUtilitiesService, + CopyCipherFieldService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; @@ -62,7 +78,9 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", + uris: [], }, + card: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -84,6 +102,8 @@ describe("ViewV2Component", () => { softDeleteWithServer: jest.fn().mockResolvedValue(undefined), }; + const cipherArchiveService = mock(); + beforeEach(async () => { mockCipherService.cipherViews$.mockClear(); mockCipherService.deleteWithServer.mockClear(); @@ -97,6 +117,10 @@ describe("ViewV2Component", () => { back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ imports: [ViewV2Component], @@ -142,6 +166,61 @@ describe("ViewV2Component", () => { provide: PasswordRepromptService, useValue: mockPasswordRepromptService, }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: CipherRiskService, + useValue: mock(), + }, ], }) .overrideProvider(DialogService, { @@ -154,6 +233,7 @@ describe("ViewV2Component", () => { fixture = TestBed.createComponent(ViewV2Component); component = fixture.componentInstance; fixture.detectChanges(); + (component as any).showFooter$ = of(true); }); describe("queryParams", () => { @@ -352,6 +432,93 @@ describe("ViewV2Component", () => { })); }); + describe("archive button", () => { + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false)); + component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + + describe("archive", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + }); + + it("calls archive service to archive the cipher", async () => { + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archive service to unarchive the cipher", async () => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + ); + }); + }); + describe("delete", () => { beforeEach(() => { component.cipher = mockCipher; diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 1dea91c0b9f..64fa42bb2ba 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; 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"; @@ -42,6 +43,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, ChangeLoginPasswordService, CipherViewComponent, CopyCipherFieldService, @@ -114,6 +116,10 @@ export class ViewV2Component { senderTabId?: number; protected showFooter$: Observable; + protected userCanArchive$ = this.accountService.activeAccount$ + .pipe(getUserId) + .pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId))); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; constructor( private passwordRepromptService: PasswordRepromptService, @@ -131,6 +137,8 @@ export class ViewV2Component { protected cipherAuthorizationService: CipherAuthorizationService, private copyCipherFieldService: CopyCipherFieldService, private popupScrollPositionService: VaultPopupScrollPositionService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -272,6 +280,24 @@ export class ViewV2Component { }); }; + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = new Date(cipherResponse.archivedDate); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = null; + }; + protected deleteCipher() { return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 16256ab875a..c863608ba10 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -2,7 +2,8 @@ {{ title }} - @if (cipherIsArchived) { + + @if (isCipherArchived) { {{ "archived" | i18n }} } @@ -83,8 +84,28 @@ } - @if (showDelete) { + @if (showActionButtons) { + @if (userCanArchive$ | async) { + @if (isCipherArchived) { + + } + @if (cipher.canBeArchived) { + + } + } { { provide: DIALOG_DATA, useValue: { ...baseParams } }, { provide: DialogRef, useValue: {} }, { provide: DialogService, useValue: {} }, - { provide: ToastService, useValue: {} }, + { + provide: ToastService, + useValue: { + showToast: () => {}, + }, + }, { provide: MessagingService, useValue: {} }, { provide: LogService, useValue: {} }, { provide: CipherService, useValue: {} }, - { provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } }, - { provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => Promise.resolve(false), + getFeatureFlag$: () => of(false), + }, + }, { provide: Router, useValue: {} }, { provide: ActivatedRoute, useValue: {} }, { @@ -89,8 +116,63 @@ describe("VaultItemDialogComponent", () => { { provide: ApiService, useValue: {} }, { provide: EventCollectionService, useValue: {} }, { provide: RoutedVaultFilterService, useValue: {} }, + { + provide: CipherArchiveService, + useValue: { + userCanArchive$: jest.fn().mockReturnValue(of(true)), + hasArchiveFlagEnabled$: jest.fn().mockReturnValue(of(true)), + archiveWithServer: jest.fn().mockResolvedValue({}), + unarchiveWithServer: jest.fn().mockResolvedValue({}), + }, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: PlatformUtilsService, + useValue: { + getClientType: jest.fn().mockReturnValue("Web"), + }, + }, { provide: SyncService, useValue: {} }, - { provide: PlatformUtilsService, useValue: {} }, + { provide: CipherRiskService, useValue: {} }, ], }).compileComponents(); @@ -140,10 +222,84 @@ describe("VaultItemDialogComponent", () => { expect(component.getTestTitle()).toBe("newItemHeaderCard"); }); }); + + describe("archive", () => { + it("calls archiveService to archive the cipher", async () => { + const archiveService = TestBed.inject(CipherArchiveService); + component.setTestCipher({ id: "111-222-333-4444" }); + component.setTestParams({ mode: "view" }); + fixture.detectChanges(); + + await component.archive(); + + expect(archiveService.archiveWithServer).toHaveBeenCalledWith("111-222-333-4444", "UserId"); + }); + }); + + describe("unarchive", () => { + it("calls archiveService to unarchive the cipher", async () => { + const archiveService = TestBed.inject(CipherArchiveService); + component.setTestCipher({ id: "111-222-333-4444" }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + + await component.unarchive(); + + expect(archiveService.unarchiveWithServer).toHaveBeenCalledWith("111-222-333-4444", "UserId"); + }); + }); + + describe("archive button", () => { + it("should show archive button when the user can archive the item and the item can be archived", () => { + component.setTestCipher({ canBeArchived: true }); + (component as any).userCanArchive$ = of(true); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeTruthy(); + }); + + it("should not show archive button when the user cannot archive the item", () => { + (component as any).userCanArchive$ = of(false); + component.setTestCipher({}); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeFalsy(); + }); + + it("should not show archive button when the item cannot be archived", () => { + component.setTestCipher({ canBeArchived: false }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeFalsy(); + }); + }); + + describe("unarchive button", () => { + it("should show the unarchive button when the item is archived", () => { + component.setTestCipher({ isArchived: true }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']")); + expect(unarchiveButton).toBeTruthy(); + }); + + it("should not show the unarchive button when the item is not archived", () => { + component.setTestCipher({ isArchived: false }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']")); + expect(unarchiveButton).toBeFalsy(); + }); + }); + describe("submitButtonText$", () => { it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); - component["cipherIsArchived"] = true; + component.setTestCipher({ isArchived: true }); + fixture.detectChanges(); component["submitButtonText$"].subscribe((text) => { expect(text).toBe("unArchiveAndSave"); @@ -153,7 +309,8 @@ describe("VaultItemDialogComponent", () => { it("should return 'save' when cipher is archived and user has premium", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(true)); - component["cipherIsArchived"] = true; + component.setTestCipher({ isArchived: true }); + fixture.detectChanges(); component["submitButtonText$"].subscribe((text) => { expect(text).toBe("save"); @@ -163,7 +320,8 @@ describe("VaultItemDialogComponent", () => { it("should return 'save' when cipher is not archived", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); - component["cipherIsArchived"] = false; + component.setTestCipher({ isArchived: false }); + fixture.detectChanges(); component["submitButtonText$"].subscribe((text) => { expect(text).toBe("save"); 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 15e62eaf93e..8e322b6fbd6 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 @@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; 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"; @@ -231,6 +232,18 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { ), ); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + + protected userId$ = this.accountService.activeAccount$.pipe(getUserId); + + /** + * Flag to indicate if the user can archive items. + * @protected + */ + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.archiveService.userCanArchive$(userId)), + ); + protected get isTrashFilter() { return this.filter?.type === "trash"; } @@ -243,6 +256,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.isTrashFilter && !this.showRestore; } + protected get showActionButtons() { + return this.cipher !== null && this.params.mode === "form" && this.formConfig.mode !== "clone"; + } + /** * Determines if the user may restore the item. * A user may restore items if they have delete permissions and the item is in the trash. @@ -253,8 +270,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected showRestore: boolean; - protected cipherIsArchived: boolean = false; - protected get loadingForm() { return this.loadForm && !this.formReady; } @@ -267,15 +282,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.showCipherView && !this.isTrashFilter && !this.showRestore; } - protected get showDelete() { - // Don't show the delete button when cloning a cipher - if (this.params.mode == "form" && this.formConfig.mode === "clone") { - return false; - } - // Never show the delete button for new ciphers - return this.cipher != null; - } - protected get showCipherView() { return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); } @@ -283,13 +289,17 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected get submitButtonText$(): Observable { return this.userHasPremium$.pipe( map((hasPremium) => - this.cipherIsArchived && !hasPremium + this.isCipherArchived && !hasPremium ? this.i18nService.t("unArchiveAndSave") : this.i18nService.t("save"), ), ); } + protected get isCipherArchived() { + return this.cipher?.isArchived; + } + /** * Flag to initialize/attach the form component. */ @@ -327,6 +337,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private apiService: ApiService, private eventCollectionService: EventCollectionService, private routedVaultFilterService: RoutedVaultFilterService, + private archiveService: CipherArchiveService, ) { this.updateTitle(); this.premiumUpgradeService.upgradeConfirmed$ @@ -339,7 +350,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { async ngOnInit() { this.cipher = await this.getDecryptedCipherView(this.formConfig); - if (this.cipher) { if (this.cipher.decryptionFailure) { this.dialogService.open(DecryptionFailureDialogComponent, { @@ -350,8 +360,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return; } - this.cipherIsArchived = this.cipher.isArchived; - this.collections = this.formConfig.collections.filter((c) => this.cipher.collectionIds?.includes(c.id), ); @@ -406,15 +414,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { cipherView.collectionIds?.includes(c.id), ); - // Track cipher archive state for btn text and badge updates - this.cipherIsArchived = this.cipher.isArchived; - // 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.initialValues = null; } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const activeUserId = await firstValueFrom(this.userId$); let cipher = await this.cipherService.get(cipherView.id, activeUserId); // When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint (if not found in local state) @@ -508,9 +513,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { result.action === AttachmentDialogResult.Removed || result.action === AttachmentDialogResult.Uploaded ) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.userId$); let updatedCipherView: CipherView; @@ -562,11 +565,72 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { await this.changeMode("view"); }; + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipher.archivedDate = archivedDate; + this.cipher.revisionDate = revisionDate; + + // If we're in View mode, we don't need to update the form. + if (this.params.mode === "view") { + return; + } + + this.cipherFormComponent.patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const activeUserId = await firstValueFrom(this.userId$); + try { + const cipherResponse = await this.archiveService.archiveWithServer( + this.cipher.id as CipherId, + activeUserId, + ); + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + cipherResponse.archivedDate ? new Date(cipherResponse.archivedDate) : null, + ); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemsWereSentToArchive"), + }); + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + } + }; + + unarchive = async () => { + const activeUserId = await firstValueFrom(this.userId$); + try { + const cipherResponse = await this.archiveService.unarchiveWithServer( + this.cipher.id as CipherId, + activeUserId, + ); + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), + }); + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } + }; + private async getDecryptedCipherView(config: CipherFormConfig) { if (config.originalCipher == null) { return; } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const activeUserId = await firstValueFrom(this.userId$); return await this.cipherService.decrypt(config.originalCipher, activeUserId); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5952abef7fc..1ec92241671 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11679,6 +11679,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, diff --git a/libs/common/src/vault/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts index 0969b7de1ac..3a5071dc51a 100644 --- a/libs/common/src/vault/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -3,12 +3,14 @@ import { Observable } from "rxjs"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { CipherData } from "../models/data/cipher.data"; + export abstract class CipherArchiveService { abstract hasArchiveFlagEnabled$: Observable; abstract archivedCiphers$(userId: UserId): Observable; abstract userCanArchive$(userId: UserId): Observable; abstract userHasPremium$(userId: UserId): Observable; - abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; - abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract showSubscriptionEndedMessaging$(userId: UserId): Observable; } diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index db360f7f991..89f59665681 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata { return this.item?.subTitle; } + get canBeArchived(): boolean { + return !this.isDeleted && !this.isArchived; + } + get hasPasswordHistory(): boolean { return this.passwordHistory && this.passwordHistory.length > 0; } diff --git a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts index 2078d1f29ea..60589ed58db 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -1,3 +1,7 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ import { mock } from "jest-mock-extended"; import { of, firstValueFrom, BehaviorSubject } from "rxjs"; diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index 12d67ab07f9..0f1120b1de8 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -18,6 +18,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherArchiveService } from "../abstractions/cipher-archive.service"; +import { CipherData } from "../models/data/cipher.data"; export class DefaultCipherArchiveService implements CipherArchiveService { constructor( @@ -84,15 +85,17 @@ export class DefaultCipherArchiveService implements CipherArchiveService { ); } - async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + // prevent mutating ciphers$ state + const localCiphers = structuredClone(currentCiphers); for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; + const localCipher = localCiphers[cipher.id as CipherId]; if (localCipher == null) { continue; @@ -102,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(Object.values(localCiphers), userId); + return response.data[0]; } - async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + // prevent mutating ciphers$ state + const localCiphers = structuredClone(currentCiphers); for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; + const localCipher = localCiphers[cipher.id as CipherId]; if (localCipher == null) { continue; @@ -123,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(Object.values(localCiphers), userId); + return response.data[0]; } } diff --git a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts index 76a4073325e..5df1bff9a56 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts @@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => { const mockCipher = new CipherView(); mockCipher.id = "cipher-id" as CipherId; const mockUserId = "user-id"; + const mockCipherData = { id: mockCipher.id } as CipherData; beforeEach(() => { cipherArchiveService = mock(); @@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); - cipherArchiveService.archiveWithServer.mockResolvedValue(undefined); - cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined); + cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData); i18nService.t.mockImplementation((key) => key); service = new ArchiveCipherUtilitiesService( diff --git a/libs/vault/src/services/archive-cipher-utilities.service.ts b/libs/vault/src/services/archive-cipher-utilities.service.ts index bbe7dba6715..5d3c5c33236 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -25,11 +25,18 @@ export class ArchiveCipherUtilitiesService { private accountService: AccountService, ) {} - /** Archive a cipher, with confirmation dialog and password reprompt checks. */ - async archiveCipher(cipher: CipherView) { - const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); - if (!repromptPassed) { - return; + /** Archive a cipher, with confirmation dialog and password reprompt checks. + * + * @param cipher The cipher to archive + * @param skipReprompt Whether to skip the password reprompt check + * @returns The archived CipherData on success, or undefined on failure or cancellation + */ + async archiveCipher(cipher: CipherView, skipReprompt = false) { + if (!skipReprompt) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } } const confirmed = await this.dialogService.openSimpleDialog({ @@ -43,38 +50,47 @@ export class ArchiveCipherUtilitiesService { } const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .archiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasSentToArchive"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.archiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasSentToArchive"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } - /** Unarchives a cipher */ + /** Unarchives a cipher + * @param cipher The cipher to unarchive + * @returns The unarchived cipher on success, or undefined on failure + */ async unarchiveCipher(cipher: CipherView) { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .unarchiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasUnarchived"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.unarchiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } }