1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +00:00

[PM-24533] Initialize Archive Feature (#16226)

* [PM-19237] Add Archive Filter Type (#13852)
* Browser can archive and unarchive items
* Create Archive Cipher Service
* Add flag and premium permissions to Archive 

---------

Co-authored-by: SmithThe4th <gsmith@bitwarden.com>
Co-authored-by: Shane <smelton@bitwarden.com>
Co-authored-by: Patrick Pimentel <ppimentel@bitwarden.com>
This commit is contained in:
Jason Ng
2025-09-22 11:06:02 -04:00
committed by GitHub
parent 04881556df
commit dbec02cf8d
48 changed files with 1166 additions and 62 deletions

View File

@@ -7,13 +7,13 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { CipherArchiveService } from "@bitwarden/vault";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
@@ -51,10 +51,10 @@ export class VaultFilterComponent
protected toastService: ToastService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
) {
super(
vaultFilterService,
@@ -64,10 +64,10 @@ export class VaultFilterComponent
toastService,
billingApiService,
dialogService,
configService,
accountService,
restrictedItemTypesService,
cipherService,
cipherArchiveService,
);
}

View File

@@ -17,7 +17,6 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -25,6 +24,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { CipherArchiveService } from "@bitwarden/vault";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { VaultFilterService } from "../services/abstractions/vault-filter.service";
@@ -112,6 +112,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
if (this.activeFilter.isDeleted) {
return "searchTrash";
}
if (this.activeFilter.isArchived) {
return "searchArchive";
}
if (this.activeFilter.cipherType === CipherType.Login) {
return "searchLogin";
}
@@ -153,10 +156,10 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected toastService: ToastService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
) {}
async ngOnInit(): Promise<void> {
@@ -248,11 +251,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
};
async buildAllFilters(): Promise<VaultFilterList> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const builderFilter = {} as VaultFilterList;
builderFilter.organizationFilter = await this.addOrganizationFilter();
builderFilter.typeFilter = await this.addTypeFilter();
builderFilter.folderFilter = await this.addFolderFilter();
builderFilter.collectionFilter = await this.addCollectionFilter();
if (
(await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId))) ||
(await firstValueFrom(this.cipherArchiveService.showArchiveVault$(userId)))
) {
builderFilter.archiveFilter = await this.addArchiveFilter();
}
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;
}
@@ -412,4 +422,31 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
};
return trashFilterSection;
}
protected async addArchiveFilter(): Promise<VaultFilterSection> {
const archiveFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
{
id: "headArchive",
name: "HeadArchive",
type: "archive",
icon: "bwi-archive",
},
[
{
id: "archive",
name: this.i18nService.t("archive"),
type: "archive",
icon: "bwi-archive",
},
],
),
header: {
showHeader: false,
isSelectable: true,
},
action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
};
return archiveFilterSection;
}
}

View File

@@ -174,6 +174,11 @@ function createLegacyFilterForEndUser(
{ id: "trash", name: "", type: "trash", icon: "" },
null,
);
} else if (filter.type !== undefined && filter.type === "archive") {
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
{ id: "archive", name: "", type: "archive", icon: "" },
null,
);
} else if (filter.type !== undefined && filter.type !== "trash") {
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
cipherTypeTree,

View File

@@ -9,7 +9,10 @@ import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model";
export type FilterFunction = (cipher: CipherViewLike) => boolean;
export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction {
export function createFilterFunction(
filter: RoutedVaultFilterModel,
archiveEnabled?: boolean,
): FilterFunction {
return (cipher) => {
const type = CipherViewLikeUtils.getType(cipher);
const isDeleted = CipherViewLikeUtils.isDeleted(cipher);
@@ -39,6 +42,15 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc
if (filter.type !== "trash" && isDeleted) {
return false;
}
// Archive filter logic is only applied if the feature flag is enabled
if (archiveEnabled) {
if (filter.type === "archive" && !CipherViewLikeUtils.isArchived(cipher)) {
return false;
}
if (filter.type !== "archive" && CipherViewLikeUtils.isArchived(cipher)) {
return false;
}
}
// No folder
if (filter.folderId === Unassigned && cipher.folderId != null) {
return false;

View File

@@ -130,6 +130,9 @@ export class RoutedVaultFilterBridge implements VaultFilter {
get isDeleted(): boolean {
return this.legacyFilter.isDeleted;
}
get isArchived(): boolean {
return this.legacyFilter.isArchived;
}
get organizationId(): string {
return this.legacyFilter.organizationId;
}

View File

@@ -15,6 +15,7 @@ const itemTypes = [
"identity",
"note",
"sshKey",
"archive",
"trash",
All,
] as const;

View File

@@ -21,6 +21,7 @@ export const VaultFilterLabel = {
TypeFilter: "typeFilter",
FolderFilter: "folderFilter",
CollectionFilter: "collectionFilter",
ArchiveFilter: "archiveFilter",
TrashFilter: "trashFilter",
} as const;

View File

@@ -72,6 +72,10 @@ export class VaultFilter {
return this.selectedCipherTypeNode?.node.type === "trash" ? true : null;
}
get isArchived(): boolean {
return this.selectedCipherTypeNode?.node.type === "archive";
}
get organizationId(): string {
return this.selectedOrganizationNode?.node.id;
}
@@ -121,6 +125,9 @@ export class VaultFilter {
if (this.isDeleted && cipherPassesFilter) {
cipherPassesFilter = cipher.isDeleted;
}
if (this.isArchived && cipherPassesFilter) {
cipherPassesFilter = cipher.isArchived;
}
if (this.cipherType && cipherPassesFilter) {
cipherPassesFilter = cipher.type === this.cipherType;
}

View File

@@ -4,7 +4,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
export type CipherStatus = "all" | "favorites" | "trash" | CipherType;
export type CipherStatus = "all" | "favorites" | "archive" | "trash" | CipherType;
export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: string };
export type CollectionFilter = CollectionAdminView & {

View File

@@ -139,6 +139,10 @@ export class VaultHeaderComponent {
return this.i18nService.t("myVault");
}
if (this.filter.type === "archive") {
return this.i18nService.t("archive");
}
const activeOrganization = this.activeOrganization;
if (activeOrganization) {
return `${activeOrganization.name} ${this.i18nService.t("vault").toLowerCase()}`;

View File

@@ -69,14 +69,18 @@
*ngIf="isEmpty && !performingInitialLoad"
>
<bit-no-items [icon]="noItemIcon">
<div slot="title">{{ "noItemsInList" | i18n }}</div>
<div slot="title" *ngIf="filter.type === 'archive'">{{ "noItemsInArchive" | i18n }}</div>
<p slot="description" class="tw-text-center tw-max-w-md" *ngIf="filter.type === 'archive'">
{{ "archivedItemsDescription" | i18n }}
</p>
<div slot="title" *ngIf="filter.type !== 'archive'">{{ "noItemsInList" | i18n }}</div>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash'"
slot="button"
*ngIf="filter.type !== 'trash' && filter.type !== 'archive'"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}

View File

@@ -77,6 +77,7 @@ import {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
CipherArchiveService,
CipherFormConfig,
CollectionAssignmentResult,
DecryptionFailureDialogComponent,
@@ -183,6 +184,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
.pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
private userCanArchive$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId);
}),
);
constructor(
private syncService: SyncService,
private route: ActivatedRoute,
@@ -213,6 +221,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private cipherFormConfigService: DefaultCipherFormConfigService,
protected billingApiService: BillingApiServiceAbstraction,
private restrictedItemTypesService: RestrictedItemTypesService,
private cipherArchiveService: CipherArchiveService,
private organizationWarningsService: OrganizationWarningsService,
) {}
@@ -309,12 +318,17 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
),
);
const ciphers$ = combineLatest([allowedCiphers$, filter$, this.currentSearchText$]).pipe(
const ciphers$ = combineLatest([
allowedCiphers$,
filter$,
this.currentSearchText$,
this.userCanArchive$,
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => {
const failedCiphers =
(await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? [];
const filterFunction = createFilterFunction(filter);
const filterFunction = createFilterFunction(filter, archiveEnabled);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];

View File

@@ -11009,6 +11009,18 @@
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
},
"searchArchive": {
"message": "Search archive"
},
"archive": {
"message": "Archive"
},
"noItemsInArchive": {
"message": "No items in archive"
},
"archivedItemsDescription": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
},
"businessUnit": {
"message": "Business Unit"
},