1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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:
Nick Krantz
2025-10-20 10:04:32 -05:00
committed by GitHub
parent 70274705fb
commit 22eb49aed1
21 changed files with 474 additions and 34 deletions

View File

@@ -11,11 +11,14 @@ import {
of,
shareReplay,
switchMap,
take,
} from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -37,6 +40,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
loaded = false;
ciphers: C[] = [];
deleted = false;
archived = false;
organization: Organization;
CipherType = CipherType;
@@ -73,13 +77,24 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
this._filter$.next(value);
}
private archiveFeatureEnabled = false;
constructor(
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
private configService: ConfigService,
) {
this.subscribeToCiphers();
// Check if archive feature flag is enabled
this.configService
.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive)
.pipe(takeUntilDestroyed(), take(1))
.subscribe((isEnabled) => {
this.archiveFeatureEnabled = isEnabled;
});
}
ngOnDestroy(): void {
@@ -87,19 +102,20 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
this.destroy$.complete();
}
async load(filter: (cipher: C) => boolean = null, deleted = false) {
async load(filter: (cipher: C) => boolean = null, deleted = false, archived = false) {
this.deleted = deleted ?? false;
this.archived = archived;
await this.applyFilter(filter);
this.loaded = true;
}
async reload(filter: (cipher: C) => boolean = null, deleted = false) {
async reload(filter: (cipher: C) => boolean = null, deleted = false, archived = false) {
this.loaded = false;
await this.load(filter, deleted);
await this.load(filter, deleted, archived);
}
async refresh() {
await this.reload(this.filter, this.deleted);
await this.reload(this.filter, this.deleted, this.archived);
}
async applyFilter(filter: (cipher: C) => boolean = null) {
@@ -125,6 +141,16 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
protected deletedFilter: (cipher: C) => boolean = (c) =>
CipherViewLikeUtils.isDeleted(c) === this.deleted;
protected archivedFilter: (cipher: C) => boolean = (c) => {
// When the archive feature is not enabled,
// always return true to avoid filtering out any items.
if (!this.archiveFeatureEnabled) {
return true;
}
return CipherViewLikeUtils.isArchived(c) === this.archived;
};
/**
* Creates stream of dependencies that results in the list of ciphers to display
* within the vault list.
@@ -158,7 +184,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
return this.searchService.searchCiphers(
userId,
searchText,
[filter, this.deletedFilter, restrictedTypeFilter],
[filter, this.deletedFilter, this.archivedFilter, restrictedTypeFilter],
allCiphers,
);
}),

View File

@@ -1,5 +1,5 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, switchMap } from "rxjs";
import { combineLatest, filter, Observable, switchMap } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -20,7 +20,7 @@ export class NewItemNudgeService extends DefaultSingleNudgeService {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
]).pipe(
switchMap(async ([nudgeStatus, ciphers]) => {
if (nudgeStatus.hasSpotlightDismissed) {

View File

@@ -9,11 +9,12 @@ import { VaultFilter } from "../models/vault-filter.model";
export class StatusFilterComponent {
@Input() hideFavorites = false;
@Input() hideTrash = false;
@Input() hideArchive = false;
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
@Input() activeFilter: VaultFilter;
get show() {
return !(this.hideFavorites && this.hideTrash);
return !(this.hideFavorites && this.hideTrash && this.hideArchive);
}
applyFilter(cipherStatus: CipherStatus) {

View File

@@ -10,6 +10,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -42,9 +43,12 @@ export class VaultFilterComponent implements OnInit {
collections: DynamicTreeNode<CollectionView>;
folders$: Observable<DynamicTreeNode<FolderView>>;
protected showArchiveVaultFilter = false;
constructor(
protected vaultFilterService: DeprecatedVaultFilterService,
protected accountService: AccountService,
protected cipherArchiveService: CipherArchiveService,
) {}
get displayCollections() {
@@ -65,6 +69,15 @@ export class VaultFilterComponent implements OnInit {
}
this.folders$ = await this.vaultFilterService.buildNestedFolders();
this.collections = await this.initCollections();
const userCanArchive = await firstValueFrom(
this.cipherArchiveService.userCanArchive$(this.activeUserId),
);
const showArchiveVault = await firstValueFrom(
this.cipherArchiveService.showArchiveVault$(this.activeUserId),
);
this.showArchiveVaultFilter = userCanArchive || showArchiveVault;
this.isLoaded = true;
}

View File

@@ -1 +1 @@
export type CipherStatus = "all" | "favorites" | "trash";
export type CipherStatus = "all" | "favorites" | "trash" | "archive";

View File

@@ -56,6 +56,34 @@ describe("VaultFilter", () => {
});
});
describe("given a archived cipher", () => {
const cipher = createCipher({ archivedDate: new Date() });
it("should return true when filtering for archive", () => {
const filterFunction = createFilterFunction({ status: "archive" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for favorites", () => {
const filterFunction = createFilterFunction({ status: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering for trash", () => {
const filterFunction = createFilterFunction({ status: "trash" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher with type", () => {
it("should return true when filter matches cipher type", () => {
const cipher = createCipher({ type: CipherType.Identity });
@@ -103,12 +131,12 @@ describe("VaultFilter", () => {
});
describe("given a cipher without folder", () => {
const cipher = createCipher({ folderId: null });
const cipher = createCipher({ folderId: undefined });
it("should return true when filtering on unassigned folder", () => {
const filterFunction = createFilterFunction({
selectedFolder: true,
selectedFolderId: null,
selectedFolderId: undefined,
});
const result = filterFunction(cipher);
@@ -175,7 +203,7 @@ describe("VaultFilter", () => {
it("should return true when filtering for unassigned collection", () => {
const filterFunction = createFilterFunction({
selectedCollection: true,
selectedCollectionId: null,
selectedCollectionId: undefined,
});
const result = filterFunction(cipher);
@@ -195,12 +223,12 @@ describe("VaultFilter", () => {
});
describe("given an individual cipher (without organization or collection)", () => {
const cipher = createCipher({ organizationId: null, collectionIds: [] });
const cipher = createCipher({ organizationId: undefined, collectionIds: [] });
it("should return false when filtering for unassigned collection", () => {
const filterFunction = createFilterFunction({
selectedCollection: true,
selectedCollectionId: null,
selectedCollectionId: undefined,
});
const result = filterFunction(cipher);
@@ -209,7 +237,7 @@ describe("VaultFilter", () => {
});
it("should return true when filtering for my vault only", () => {
const cipher = createCipher({ organizationId: null });
const cipher = createCipher({ organizationId: undefined });
const filterFunction = createFilterFunction({
myVaultOnly: true,
});
@@ -230,11 +258,12 @@ function createCipher(options: Partial<CipherView> = {}) {
const cipher = new CipherView();
cipher.favorite = options.favorite ?? false;
cipher.deletedDate = options.deletedDate;
cipher.type = options.type;
cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId;
cipher.deletedDate = options.deletedDate ?? null;
cipher.archivedDate = options.archivedDate ?? null;
cipher.type = options.type ?? CipherType.Login;
cipher.folderId = options.folderId ?? undefined;
cipher.collectionIds = options.collectionIds ?? [];
cipher.organizationId = options.organizationId ?? undefined;
return cipher;
}

View File

@@ -50,6 +50,9 @@ export class VaultFilter {
if (this.status === "trash" && cipherPassesFilter) {
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
}
if (this.status === "archive" && cipherPassesFilter) {
cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher);
}
if (this.cipherType != null && cipherPassesFilter) {
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
}

View File

@@ -160,6 +160,10 @@ export class CipherView implements View, InitializerMetadata {
}
get canAssignToCollections(): boolean {
if (this.isArchived) {
return false;
}
if (this.organizationId == null) {
return true;
}

View File

@@ -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", () => {

View File

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

View File

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

View 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",
});
});
});
});

View 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"),
});
});
}
}