[PM-24560] - Add Archive UI Element to View and Edit Item Cards (#16954)
Some checks failed
Testing / Run tests (push) Has been cancelled
Testing / Run Rust tests on macos-14 (push) Has been cancelled
Testing / Run Rust tests on ubuntu-22.04 (push) Has been cancelled
Testing / Run Rust tests on windows-2022 (push) Has been cancelled
Auto Update Branch / Update Branch (push) Has been cancelled
Chromatic / Check PR run (push) Has been cancelled
Lint / Lint (push) Has been cancelled
Lint / Run Rust lint on macos-14 (push) Has been cancelled
Lint / Run Rust lint on ubuntu-24.04 (push) Has been cancelled
Lint / Run Rust lint on windows-2022 (push) Has been cancelled
Scan / Check PR run (push) Has been cancelled
Testing / Rust Coverage (push) Has been cancelled
Chromatic / Chromatic (push) Has been cancelled
Scan / Checkmarx (push) Has been cancelled
Scan / Sonar (push) Has been cancelled
Testing / Upload to Codecov (push) Has been cancelled

* 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$
This commit is contained in:
Jordan Aasen 2026-01-09 16:39:22 -08:00 committed by GitHub
parent 1714660bde
commit 404d925f84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 824 additions and 122 deletions

View file

@ -573,6 +573,9 @@
"itemWasSentToArchive": {
"message": "Item was sent to archive"
},
"itemWasUnarchived": {
"message": "Item was unarchived"
},
"itemUnarchived": {
"message": "Item was unarchived"
},

View file

@ -31,14 +31,34 @@
{{ "cancel" | i18n }}
</button>
<button
slot="end"
*ngIf="canDeleteCipher$ | async"
[bitAction]="delete"
type="button"
buttonType="danger"
bitIconButton="bwi-trash"
[label]="'delete' | i18n"
></button>
<ng-container slot="end">
@if (isEditMode) {
@if ((archiveFlagEnabled$ | async) && isCipherArchived) {
<button
type="button"
[bitAction]="unarchive"
bitIconButton="bwi-unarchive"
[label]="'unarchive' | i18n"
></button>
}
@if ((userCanArchive$ | async) && canCipherBeArchived) {
<button
type="button"
[bitAction]="archive"
bitIconButton="bwi-archive"
[label]="'archiveVerb' | i18n"
></button>
}
}
@if (canDeleteCipher$ | async) {
<button
[bitAction]="delete"
type="button"
buttonType="danger"
bitIconButton="bwi-trash"
[label]="'delete' | i18n"
></button>
}
</ng-container>
</popup-footer>
</popup-page>

View file

@ -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<CipherService>;
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<CipherArchiveService>();
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<AddEditCipherInfo | null>(null);
cipherServiceMock = mock<CipherService>({
@ -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

View file

@ -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<Record<keyof QueryParams, string>>;
],
})
export class AddEditV2Component implements OnInit, OnDestroy {
readonly cipherFormComponent = viewChild(CipherFormComponent);
headerText: string;
config: CipherFormConfig;
canDeleteCipher$: Observable<boolean>;
@ -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" },

View file

@ -3,37 +3,47 @@
<app-pop-out slot="end" />
</popup-header>
<app-cipher-view *ngIf="cipher" [cipher]="cipher"></app-cipher-view>
@if (cipher) {
<app-cipher-view [cipher]="cipher"></app-cipher-view>
}
<popup-footer slot="footer" *ngIf="showFooter$ | async">
<button
*ngIf="!cipher.isDeleted"
buttonType="primary"
type="button"
bitButton
(click)="editCipher()"
>
{{ "edit" | i18n }}
</button>
<button
*ngIf="cipher.isDeleted && cipher.permissions.restore"
buttonType="primary"
type="button"
bitButton
[bitAction]="restore"
>
{{ "restore" | i18n }}
</button>
<button
slot="end"
*ngIf="canDeleteCipher$ | async"
[bitAction]="delete"
type="button"
buttonType="danger"
bitIconButton="bwi-trash"
[label]="(cipher.isDeleted ? 'deleteForever' : 'delete') | i18n"
></button>
@if (!cipher.isDeleted) {
<button buttonType="primary" type="button" bitButton (click)="editCipher()">
{{ "edit" | i18n }}
</button>
}
@if (cipher.isDeleted && cipher.permissions.restore) {
<button buttonType="primary" type="button" bitButton [bitAction]="restore">
{{ "restore" | i18n }}
</button>
}
<ng-container slot="end">
@if ((archiveFlagEnabled$ | async) && cipher.isArchived) {
<button
type="button"
[bitAction]="unarchive"
bitIconButton="bwi-unarchive"
[label]="'unarchive' | i18n"
></button>
}
@if ((userCanArchive$ | async) && cipher.canBeArchived) {
<button
type="button"
[bitAction]="archive"
bitIconButton="bwi-archive"
[label]="'archiveVerb' | i18n"
></button>
}
@if (canDeleteCipher$ | async) {
<button
[bitAction]="delete"
type="button"
buttonType="danger"
bitIconButton="bwi-trash"
[label]="(cipher.isDeleted ? 'deleteForever' : 'delete') | i18n"
></button>
}
</ng-container>
</popup-footer>
</popup-page>

View file

@ -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<CipherArchiveService>();
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<OrganizationService>(),
},
{
provide: CollectionService,
useValue: mock<CollectionService>(),
},
{
provide: FolderService,
useValue: mock<FolderService>(),
},
{
provide: TaskService,
useValue: mock<TaskService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{
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<CipherRiskService>(),
},
],
})
.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;

View file

@ -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<boolean>;
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)

View file

@ -2,7 +2,8 @@
<span bitDialogTitle aria-live="polite">
{{ title }}
</span>
@if (cipherIsArchived) {
@if (isCipherArchived) {
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
}
@ -83,8 +84,28 @@
</button>
}
@if (showDelete) {
@if (showActionButtons) {
<div class="tw-ml-auto">
@if (userCanArchive$ | async) {
@if (isCipherArchived) {
<button
type="button"
class="tw-mr-1"
[bitAction]="unarchive"
bitIconButton="bwi-unarchive"
[label]="'unArchive' | i18n"
></button>
}
@if (cipher.canBeArchived) {
<button
type="button"
class="tw-mr-1"
[bitAction]="archive"
bitIconButton="bwi-archive"
[label]="'archiveVerb' | i18n"
></button>
}
}
<button
bitIconButton="bwi-trash"
type="button"

View file

@ -1,22 +1,32 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { ActivatedRoute, Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } 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 { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
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 { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
@ -33,7 +43,13 @@ class TestVaultItemDialogComponent extends VaultItemDialogComponent {
this.params = params;
}
setTestCipher(cipher: any) {
this.cipher = cipher;
this.cipher = {
...cipher,
login: {
uris: [],
},
card: {},
};
}
setTestFormConfig(formConfig: any) {
this.formConfig = formConfig;
@ -72,12 +88,23 @@ describe("VaultItemDialogComponent", () => {
{ 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<OrganizationService>(),
},
{
provide: CollectionService,
useValue: mock<CollectionService>(),
},
{
provide: FolderService,
useValue: mock<FolderService>(),
},
{
provide: TaskService,
useValue: mock<TaskService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{
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");

View file

@ -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<string> {
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);
}

View file

@ -11679,6 +11679,9 @@
"itemsWereSentToArchive": {
"message": "Items were sent to archive"
},
"itemWasUnarchived": {
"message": "Item was unarchived"
},
"itemUnarchived": {
"message": "Item was unarchived"
},

View file

@ -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<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract userHasPremium$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
}

View file

@ -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;
}

View file

@ -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";

View file

@ -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<void> {
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
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<void> {
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
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];
}
}

View file

@ -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<CipherArchiveService>();
@ -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(

View file

@ -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;
}
}
}