mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 07:13:32 +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:
@@ -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,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type CipherStatus = "all" | "favorites" | "trash";
|
||||
export type CipherStatus = "all" | "favorites" | "trash" | "archive";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user