mirror of
https://github.com/bitwarden/browser
synced 2025-12-24 04:04:24 +00:00
[PM-19337] Desktop Archive (#16787)
* fix typescript errors * add archive filter to desktop * exclude archive items from search * add left click menu options for archive * add MP prompt checks for archive/unarchive * assure that a cipher cannot be assigned to collections when archived * move cipher from archive vault if a user loses premium * ensure clone only shows when archive is active * refactor right side footer actions to getter so it can be expanded * add confirmation prompt for archiving cipher * add utility service for archiving/unarchiving a cipher * add archive/unarchive ability to footer of desktop * add tests for utilities service * handle null emission of `cipherViews$` * use active user id directly from activeAccount * remove unneeded load of vault items * refresh internal cipher when archive is toggled - forcing the footer view to update * refresh current cipher when archived from the left-click menu * only show archive for viewing a cipher * add cipher form tests * clear archive date when soft deleting * update success messages * remove archive date when cloning * fix crowdin message swap * fix test * move MP prompt before archive prompt - match PM-26994 * fix failing test * add optional chaining * move template logic into class * condense logic * `unArchive`
This commit is contained in:
@@ -2,7 +2,7 @@ import { ChangeDetectorRef } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -13,7 +13,6 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
||||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||
@@ -72,7 +71,7 @@ describe("CipherFormComponent", () => {
|
||||
});
|
||||
|
||||
it("should remove archivedDate when user cannot archive and cipher is archived", async () => {
|
||||
mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account);
|
||||
mockAccountService.activeAccount$ = of({ id: "user-id" }) as Observable<Account | null>;
|
||||
mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView());
|
||||
|
||||
@@ -154,6 +153,15 @@ describe("CipherFormComponent", () => {
|
||||
|
||||
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
|
||||
});
|
||||
|
||||
it("clears archiveDate on updatedCipherView", async () => {
|
||||
cipherView.archivedDate = new Date();
|
||||
decryptCipher.mockResolvedValue(cipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableFormFields", () => {
|
||||
|
||||
@@ -263,6 +263,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
|
||||
if (this.config.mode === "clone") {
|
||||
this.updatedCipherView.id = null;
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
|
||||
if (this.updatedCipherView.login) {
|
||||
this.updatedCipherView.login.fido2Credentials = null;
|
||||
|
||||
@@ -27,3 +27,4 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
export * from "./services/archive-cipher-utilities.service";
|
||||
|
||||
122
libs/vault/src/services/archive-cipher-utilities.service.spec.ts
Normal file
122
libs/vault/src/services/archive-cipher-utilities.service.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { ArchiveCipherUtilitiesService } from "./archive-cipher-utilities.service";
|
||||
import { PasswordRepromptService } from "./password-reprompt.service";
|
||||
|
||||
describe("ArchiveCipherUtilitiesService", () => {
|
||||
let service: ArchiveCipherUtilitiesService;
|
||||
|
||||
let cipherArchiveService: MockProxy<CipherArchiveService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let passwordRepromptService: MockProxy<PasswordRepromptService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher-id" as CipherId;
|
||||
const mockUserId = "user-id";
|
||||
|
||||
beforeEach(() => {
|
||||
cipherArchiveService = mock<CipherArchiveService>();
|
||||
dialogService = mock<DialogService>();
|
||||
passwordRepromptService = mock<PasswordRepromptService>();
|
||||
toastService = mock<ToastService>();
|
||||
i18nService = mock<I18nService>();
|
||||
accountService = mock<AccountService>();
|
||||
|
||||
accountService.activeAccount$ = new BehaviorSubject({ id: mockUserId } as any).asObservable();
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue(undefined);
|
||||
cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined);
|
||||
i18nService.t.mockImplementation((key) => key);
|
||||
|
||||
service = new ArchiveCipherUtilitiesService(
|
||||
cipherArchiveService,
|
||||
dialogService,
|
||||
passwordRepromptService,
|
||||
toastService,
|
||||
i18nService,
|
||||
accountService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("archiveCipher()", () => {
|
||||
it("returns early when confirmation dialog is cancelled", async () => {
|
||||
dialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await service.archiveCipher(mockCipher);
|
||||
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalled();
|
||||
expect(cipherArchiveService.archiveWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when password reprompt fails", async () => {
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
|
||||
|
||||
await service.archiveCipher(mockCipher);
|
||||
|
||||
expect(cipherArchiveService.archiveWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("archives cipher and shows success toast when successful", async () => {
|
||||
await service.archiveCipher(mockCipher);
|
||||
|
||||
expect(cipherArchiveService.archiveWithServer).toHaveBeenCalledWith(
|
||||
mockCipher.id,
|
||||
mockUserId,
|
||||
);
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "itemWasSentToArchive",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when archiving fails", async () => {
|
||||
cipherArchiveService.archiveWithServer.mockRejectedValue(new Error("test error"));
|
||||
|
||||
await service.archiveCipher(mockCipher);
|
||||
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveCipher()", () => {
|
||||
it("unarchives cipher and shows success toast when successful", async () => {
|
||||
await service.unarchiveCipher(mockCipher);
|
||||
|
||||
expect(cipherArchiveService.unarchiveWithServer).toHaveBeenCalledWith(
|
||||
mockCipher.id,
|
||||
mockUserId,
|
||||
);
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "itemWasUnarchived",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when unarchiving fails", async () => {
|
||||
cipherArchiveService.unarchiveWithServer.mockRejectedValue(new Error("test error"));
|
||||
|
||||
await service.unarchiveCipher(mockCipher);
|
||||
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
80
libs/vault/src/services/archive-cipher-utilities.service.ts
Normal file
80
libs/vault/src/services/archive-cipher-utilities.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PasswordRepromptService } from "./password-reprompt.service";
|
||||
|
||||
/**
|
||||
* Wrapper around {@link CipherArchiveService} to provide UI enhancements for archiving/unarchiving ciphers.
|
||||
*/
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class ArchiveCipherUtilitiesService {
|
||||
constructor(
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private dialogService: DialogService,
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
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;
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveItem" },
|
||||
content: { key: "archiveItemConfirmDesc" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
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"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Unarchives a cipher */
|
||||
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"),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user