1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +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

@@ -550,6 +550,33 @@
"resetSearch": {
"message": "Reset search"
},
"archive": {
"message": "Archive"
},
"unarchive": {
"message": "Unarchive"
},
"itemsInArchive": {
"message": "Items in archive"
},
"noItemsInArchive": {
"message": "No items in archive"
},
"noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
},
"itemSentToArchive": {
"message": "Item sent to archive"
},
"itemRemovedFromArchive": {
"message": "Item removed from archive"
},
"archiveItem": {
"message": "Archive item"
},
"archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
},
"edit": {
"message": "Edit"
},

View File

@@ -30,6 +30,7 @@ import {
LoginDecryptionOptionsComponent,
LoginSecondaryContentComponent,
LoginViaAuthRequestComponent,
NewDeviceVerificationComponent,
PasswordHintComponent,
RegistrationFinishComponent,
RegistrationStartComponent,
@@ -38,7 +39,6 @@ import {
SsoComponent,
TwoFactorAuthComponent,
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
@@ -80,6 +80,7 @@ import { canAccessAtRiskPasswords } from "../vault/popup/guards/at-risk-password
import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard";
import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard";
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
import { ArchiveComponent } from "../vault/popup/settings/archive.component";
import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component";
import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component";
import { MoreFromBitwardenPageV2Component } from "../vault/popup/settings/more-from-bitwarden-page-v2.component";
@@ -675,6 +676,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 2 } satisfies RouteDataProperties,
},
{
path: "archive",
component: ArchiveComponent,
canActivate: [authGuard],
data: { elevation: 2 } satisfies RouteDataProperties,
},
{
path: "security",
component: AnonLayoutWrapperComponent,

View File

@@ -145,6 +145,8 @@ import {
DefaultSshImportPromptService,
PasswordRepromptService,
SshImportPromptService,
CipherArchiveService,
DefaultCipherArchiveService,
} from "@bitwarden/vault";
import { AccountSwitcherService } from "../../auth/popup/account-switching/services/account-switcher.service";
@@ -703,6 +705,18 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionDeviceManagementComponentService,
deps: [],
}),
safeProvider({
provide: CipherArchiveService,
useClass: DefaultCipherArchiveService,
deps: [
CipherService,
ApiService,
DialogService,
PasswordRepromptService,
BillingAccountProfileStateService,
ConfigService,
],
}),
];
@NgModule({

View File

@@ -38,5 +38,10 @@
{{ "assignToCollections" | i18n }}
</a>
</ng-container>
@if (canArchive$ | async) {
<button type="button" bitMenuItem (click)="archive()" *ngIf="canArchive$ | async">
{{ "archive" | i18n }}
</button>
}
</bit-menu>
</bit-item-action>

View File

@@ -3,7 +3,8 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, filter, firstValueFrom, map, switchMap } from "rxjs";
import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -11,6 +12,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
@@ -26,7 +28,7 @@ import {
MenuModule,
ToastService,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { CipherArchiveService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
@@ -103,6 +105,20 @@ export class ItemMoreOptionsComponent {
}),
);
/** Observable Boolean checking if item can show Archive menu option */
protected canArchive$ = combineLatest([
this._cipher$,
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
),
]).pipe(
filter(([cipher, userId]) => cipher != null && userId != null),
map(([cipher, canArchive]) => {
return canArchive && !CipherViewLikeUtils.isArchived(cipher) && cipher.organizationId == null;
}),
);
constructor(
private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService,
@@ -116,6 +132,7 @@ export class ItemMoreOptionsComponent {
private cipherAuthorizationService: CipherAuthorizationService,
private collectionService: CollectionService,
private restrictedItemTypesService: RestrictedItemTypesService,
private cipherArchiveService: CipherArchiveService,
) {}
get canEdit() {
@@ -233,4 +250,23 @@ export class ItemMoreOptionsComponent {
queryParams: { cipherId: this.cipher.id },
});
}
async archive() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "archiveItem" },
content: { key: "archiveItemConfirmDesc" },
type: "info",
});
if (!confirmed) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemSentToArchive"),
});
}
}

View File

@@ -1,4 +1,4 @@
import { WritableSignal, signal } from "@angular/core";
import { signal, WritableSignal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs";
@@ -8,10 +8,11 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ObservableTracker, mockAccountServiceWith } from "@bitwarden/common/spec";
import { mockAccountServiceWith, ObservableTracker } from "@bitwarden/common/spec";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
@@ -25,6 +26,7 @@ import {
RestrictedItemTypesService,
} from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherArchiveService } from "@bitwarden/vault";
import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -43,7 +45,7 @@ describe("VaultPopupItemsService", () => {
let mockOrg: Organization;
let mockCollections: CollectionView[];
let activeUserLastSync$: BehaviorSubject<Date>;
let activeUserLastSync$: BehaviorSubject<Date | null>;
let viewCacheService: {
signal: jest.Mock;
mockSignal: WritableSignal<string | null>;
@@ -64,6 +66,9 @@ describe("VaultPopupItemsService", () => {
const inlineMenuFieldQualificationServiceMock = mock<InlineMenuFieldQualificationService>();
const userId = Utils.newGuid() as UserId;
const accountServiceMock = mockAccountServiceWith(userId);
const configServiceMock = mock<ConfigService>();
const cipherArchiveServiceMock = mock<CipherArchiveService>();
cipherArchiveServiceMock.userCanArchive$.mockReturnValue(of(true));
const restrictedItemTypesService = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
@@ -101,7 +106,7 @@ describe("VaultPopupItemsService", () => {
failedToDecryptCiphersSubject.asObservable(),
);
searchService.searchCiphers.mockImplementation(async (userId, _, __, ciphers) => ciphers);
searchService.searchCiphers.mockImplementation(async (userId, _, __, ciphers) => ciphers!);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
ciphers.filter((c) => ["0", "1"].includes(uuidAsString(c.id))),
);
@@ -142,8 +147,9 @@ describe("VaultPopupItemsService", () => {
organizationServiceMock.organizations$.mockReturnValue(new BehaviorSubject([mockOrg]));
collectionService.decryptedCollections$.mockReturnValue(new BehaviorSubject(mockCollections));
activeUserLastSync$ = new BehaviorSubject(new Date());
activeUserLastSync$ = new BehaviorSubject<Date | null>(new Date());
syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$);
configServiceMock.getFeatureFlag$.mockReturnValue(of(true));
const testSearchSignal = createMockSignal<string | null>("");
viewCacheService = {
@@ -168,10 +174,15 @@ describe("VaultPopupItemsService", () => {
useValue: inlineMenuFieldQualificationServiceMock,
},
{ provide: PopupViewCacheService, useValue: viewCacheService },
{ provide: ConfigService, useValue: configServiceMock },
{
provide: RestrictedItemTypesService,
useValue: restrictedItemTypesService,
},
{
provide: CipherArchiveService,
useValue: cipherArchiveServiceMock,
},
],
});
@@ -297,7 +308,7 @@ describe("VaultPopupItemsService", () => {
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async (userId, q, _, ciphers) => {
return ciphers.filter((cipher) => {
return ciphers!.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
@@ -390,12 +401,12 @@ describe("VaultPopupItemsService", () => {
});
});
it("should return true when all ciphers are deleted", (done) => {
it("should return true when all ciphers are deleted/archived", (done) => {
cipherServiceMock.cipherListViews$.mockReturnValue(
of([
{ id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true },
{ id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: false, isArchived: true },
] as CipherView[]),
);

View File

@@ -23,6 +23,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -34,6 +35,7 @@ import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherArchiveService } from "@bitwarden/vault";
import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
@@ -133,14 +135,24 @@ export class VaultPopupItemsService {
shareReplay({ refCount: true, bufferSize: 1 }),
);
private userCanArchive$ = this.activeUserId$.pipe(
switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId);
}),
);
private _activeCipherList$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) =>
combineLatest([this.organizations$, this.decryptedCollections$]).pipe(
map(([organizations, collections]) => {
combineLatest([this.organizations$, this.decryptedCollections$, this.userCanArchive$]).pipe(
map(([organizations, collections, canArchive]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
return ciphers
.filter((c) => !CipherViewLikeUtils.isDeleted(c))
.filter(
(c) =>
!CipherViewLikeUtils.isDeleted(c) &&
(!canArchive || !CipherViewLikeUtils.isArchived(c)),
)
.map((cipher) => {
(cipher as PopupCipherViewLike).collections = cipher.collectionIds?.map(
(colId) => collectionMap[colId as CollectionId],
@@ -330,6 +342,8 @@ export class VaultPopupItemsService {
private accountService: AccountService,
private ngZone: NgZone,
private restrictedItemTypesService: RestrictedItemTypesService,
private configService: ConfigService,
private cipherArchiveService: CipherArchiveService,
) {}
applyFilter(newSearchText: string) {

View File

@@ -0,0 +1,81 @@
<popup-page [loading]="loading$ | async">
<popup-header slot="header" [pageTitle]="'archive' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
@if (archivedCiphers$ | async; as archivedItems) {
@if (archivedItems.length) {
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">
{{ "itemsInArchive" | i18n }}
</h2>
<span bitTypography="body1" slot="end">{{ archivedItems.length }}</span>
</bit-section-header>
<bit-item-group>
@for (cipher of archivedItems; track cipher.id) {
<bit-item>
<button
bit-item-content
type="button"
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
(click)="view(cipher)"
>
<div slot="start" class="tw-justify-start tw-w-7 tw-flex">
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
@if (cipher.hasAttachments) {
<i class="bwi bwi-paperclip bwi-sm" [appA11yTitle]="'attachments' | i18n"></i>
}
<span slot="secondary">{{ cipher.subTitle }}</span>
</button>
<bit-item-action slot="end">
<button
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
[appA11yTitle]="'moreOptionsTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<button type="button" bitMenuItem (click)="edit(cipher)">
{{ "edit" | i18n }}
</button>
<button type="button" bitMenuItem (click)="clone(cipher)">
{{ "clone" | i18n }}
</button>
<button type="button" bitMenuItem (click)="unarchive(cipher)">
{{ "unarchive" | i18n }}
</button>
<button
type="button"
bitMenuItem
*appCanDeleteCipher="cipher"
(click)="delete(cipher)"
>
<span class="tw-text-danger">
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</bit-item-action>
</bit-item>
}
</bit-item-group>
</bit-section>
} @else {
<bit-no-items class="tw-flex tw-h-full tw-items-center tw-justify-center">
<ng-container slot="title">
{{ "noItemsInArchive" | i18n }}
</ng-container>
<ng-container slot="description">
{{ "noItemsInArchiveDesc" | i18n }}
</ng-container>
</bit-no-items>
}
}
</popup-page>

View File

@@ -0,0 +1,159 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DialogService,
IconButtonModule,
ItemModule,
MenuModule,
NoItemsModule,
SectionComponent,
SectionHeaderComponent,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { CanDeleteCipherDirective, CipherArchiveService } from "@bitwarden/vault";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@Component({
templateUrl: "archive.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
NoItemsModule,
ItemModule,
MenuModule,
IconButtonModule,
CanDeleteCipherDirective,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
],
})
export class ArchiveComponent {
private dialogService = inject(DialogService);
private router = inject(Router);
private cipherService = inject(CipherService);
private accountService = inject(AccountService);
private logService = inject(LogService);
private toastService = inject(ToastService);
private i18nService = inject(I18nService);
private cipherArchiveService = inject(CipherArchiveService);
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
protected archivedCiphers$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.archivedCiphers$(userId)),
);
protected loading$ = this.archivedCiphers$.pipe(
map(() => false),
startWith(true),
);
async view(cipher: CipherView) {
if (!(await this.cipherArchiveService.canInteract(cipher))) {
return;
}
await this.router.navigate(["/view-cipher"], {
queryParams: { cipherId: cipher.id, type: cipher.type },
});
}
async edit(cipher: CipherView) {
if (!(await this.cipherArchiveService.canInteract(cipher))) {
return;
}
await this.router.navigate(["/edit-cipher"], {
queryParams: { cipherId: cipher.id, type: cipher.type },
});
}
async delete(cipher: CipherView) {
if (!(await this.cipherArchiveService.canInteract(cipher))) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: { key: "deleteItemConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
const activeUserId = await firstValueFrom(this.userId$);
try {
await this.cipherService.softDeleteWithServer(cipher.id, activeUserId);
} catch (e) {
this.logService.error(e);
return;
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("deletedItem"),
});
}
async unarchive(cipher: CipherView) {
if (!(await this.cipherArchiveService.canInteract(cipher))) {
return;
}
const activeUserId = await firstValueFrom(this.userId$);
await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemRemovedFromArchive"),
});
}
async clone(cipher: CipherView) {
if (!(await this.cipherArchiveService.canInteract(cipher))) {
return;
}
if (cipher.login?.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
type: "info",
});
if (!confirmed) {
return;
}
}
await this.router.navigate(["/clone-cipher"], {
queryParams: {
clone: true.toString(),
cipherId: cipher.id,
type: cipher.type,
},
});
}
}

View File

@@ -34,6 +34,14 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
@if (userCanArchive() || showArchiveFilter()) {
<bit-item>
<a bit-item-content routerLink="/archive">
{{ "archive" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
}
<bit-item>
<a bit-item-content routerLink="/trash">
{{ "trash" | i18n }}

View File

@@ -1,5 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs";
@@ -10,6 +11,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
import { CipherArchiveService } from "@bitwarden/vault";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
@@ -32,6 +34,17 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
})
export class VaultSettingsV2Component implements OnInit, OnDestroy {
lastSync = "--";
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
// Check if user is premium user, they will be able to archive items
protected userCanArchive = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);
// Check if user has archived items (does not check if user is premium)
protected showArchiveFilter = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))),
);
protected emptyVaultImportBadge$ = this.accountService.activeAccount$.pipe(
getUserId,
@@ -47,6 +60,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
private i18nService: I18nService,
private nudgeService: NudgesService,
private accountService: AccountService,
private cipherArchiveService: CipherArchiveService,
) {}
async ngOnInit() {

View File

@@ -4107,5 +4107,32 @@
},
"editShortcut": {
"message": "Edit shortcut"
},
"archive": {
"message": "Archive"
},
"unarchive": {
"message": "Unarchive"
},
"itemsInArchive": {
"message": "Items in archive"
},
"noItemsInArchive": {
"message": "No items in archive"
},
"noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
},
"itemSentToArchive": {
"message": "Item sent to archive"
},
"itemRemovedFromArchive": {
"message": "Item removed from archive"
},
"archiveItem": {
"message": "Archive item"
},
"archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
}
}

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"
},

View File

@@ -292,6 +292,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
@@ -340,7 +341,11 @@ import {
import { SafeInjectionToken } from "@bitwarden/ui-common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
import {
CipherArchiveService,
DefaultCipherArchiveService,
PasswordRepromptService,
} from "@bitwarden/vault";
import {
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
@@ -1620,6 +1625,18 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction,
],
}),
safeProvider({
provide: CipherArchiveService,
useClass: DefaultCipherArchiveService,
deps: [
CipherServiceAbstraction,
ApiServiceAbstraction,
DialogService,
PasswordRepromptService,
BillingAccountProfileStateService,
ConfigService,
],
}),
];
@NgModule({

View File

@@ -53,6 +53,9 @@ export enum FeatureFlag {
IpcChannelFramework = "ipc-channel-framework",
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -112,6 +115,9 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.InactiveUserServerNotification]: FALSE,
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -36,6 +36,7 @@ export class CipherExport {
req.creationDate = null;
req.revisionDate = null;
req.deletedDate = null;
req.archivedDate = null;
return req;
}
@@ -84,6 +85,7 @@ export class CipherExport {
view.creationDate = req.creationDate ? new Date(req.creationDate) : view.creationDate;
view.revisionDate = req.revisionDate ? new Date(req.revisionDate) : view.revisionDate;
view.deletedDate = req.deletedDate ? new Date(req.deletedDate) : view.deletedDate;
view.archivedDate = req.archivedDate ? new Date(req.archivedDate) : view.archivedDate;
return view;
}
@@ -128,6 +130,7 @@ export class CipherExport {
domain.creationDate = req.creationDate ? new Date(req.creationDate) : null;
domain.revisionDate = req.revisionDate ? new Date(req.revisionDate) : null;
domain.deletedDate = req.deletedDate ? new Date(req.deletedDate) : null;
domain.archivedDate = req.archivedDate ? new Date(req.archivedDate) : null;
return domain;
}
@@ -149,6 +152,7 @@ export class CipherExport {
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
archivedDate: Date = null;
key: string;
// Use build method instead of ctor so that we can control order of JSON stringify for pretty print
@@ -195,5 +199,6 @@ export class CipherExport {
this.creationDate = o.creationDate;
this.revisionDate = o.revisionDate;
this.deletedDate = o.deletedDate;
this.archivedDate = o.archivedDate;
}
}

View File

@@ -39,7 +39,8 @@ export class CipherData {
passwordHistory?: PasswordHistoryData[];
collectionIds?: string[];
creationDate: string;
deletedDate: string | null;
deletedDate: string | undefined;
archivedDate: string | undefined;
reprompt: CipherRepromptType;
key: string;
@@ -63,6 +64,7 @@ export class CipherData {
this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds;
this.creationDate = response.creationDate;
this.deletedDate = response.deletedDate;
this.archivedDate = response.archivedDate;
this.reprompt = response.reprompt;
this.key = response.key;

View File

@@ -60,13 +60,14 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
localData: null,
creationDate: null,
deletedDate: null,
deletedDate: undefined,
reprompt: undefined,
attachments: null,
fields: null,
passwordHistory: null,
key: null,
permissions: undefined,
archivedDate: undefined,
});
});
@@ -84,7 +85,7 @@ describe("Cipher DTO", () => {
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.deletedDate = undefined;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
@@ -123,7 +124,7 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
@@ -149,10 +150,11 @@ describe("Cipher DTO", () => {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
permissions: new CipherPermissionsApi(),
reprompt: CipherRepromptType.None,
key: "EncryptedString",
archivedDate: undefined,
login: {
uris: [
{
@@ -224,10 +226,11 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
permissions: new CipherPermissionsApi(),
reprompt: 0,
key: { encryptedString: "EncryptedString", encryptionType: 0 },
archivedDate: undefined,
login: {
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
autofillOnPageLoad: false,
@@ -302,10 +305,11 @@ describe("Cipher DTO", () => {
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.deletedDate = undefined;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
cipher.archivedDate = undefined;
const loginView = new LoginView();
loginView.username = "username";
@@ -347,10 +351,11 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
});
});
});
@@ -372,13 +377,14 @@ describe("Cipher DTO", () => {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
reprompt: CipherRepromptType.None,
key: "EncKey",
secureNote: {
type: SecureNoteType.Generic,
},
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
};
});
@@ -401,7 +407,7 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
secureNote: { type: SecureNoteType.Generic },
attachments: null,
@@ -409,6 +415,7 @@ describe("Cipher DTO", () => {
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
});
});
@@ -431,12 +438,13 @@ describe("Cipher DTO", () => {
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.deletedDate = undefined;
cipher.reprompt = CipherRepromptType.None;
cipher.secureNote = new SecureNote();
cipher.secureNote.type = SecureNoteType.Generic;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
cipher.archivedDate = undefined;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
@@ -470,10 +478,11 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
});
});
});
@@ -495,7 +504,7 @@ describe("Cipher DTO", () => {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
permissions: new CipherPermissionsApi(),
reprompt: CipherRepromptType.None,
card: {
@@ -507,6 +516,7 @@ describe("Cipher DTO", () => {
code: "EncryptedString",
},
key: "EncKey",
archivedDate: undefined,
};
});
@@ -529,7 +539,7 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
card: {
cardholderName: { encryptedString: "EncryptedString", encryptionType: 0 },
@@ -544,6 +554,7 @@ describe("Cipher DTO", () => {
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
});
});
@@ -566,10 +577,11 @@ describe("Cipher DTO", () => {
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.deletedDate = undefined;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
cipher.archivedDate = undefined;
const cardView = new CardView();
cardView.cardholderName = "cardholderName";
@@ -611,10 +623,11 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
});
});
});
@@ -636,10 +649,11 @@ describe("Cipher DTO", () => {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
permissions: new CipherPermissionsApi(),
reprompt: CipherRepromptType.None,
key: "EncKey",
archivedDate: undefined,
identity: {
title: "EncryptedString",
firstName: "EncryptedString",
@@ -682,8 +696,9 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
archivedDate: undefined,
identity: {
title: { encryptedString: "EncryptedString", encryptionType: 0 },
firstName: { encryptedString: "EncryptedString", encryptionType: 0 },
@@ -731,10 +746,11 @@ describe("Cipher DTO", () => {
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.deletedDate = undefined;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
cipher.archivedDate = undefined;
const identityView = new IdentityView();
identityView.firstName = "firstName";
@@ -776,10 +792,11 @@ describe("Cipher DTO", () => {
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
deletedDate: undefined,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
archivedDate: undefined,
});
});
});
@@ -793,6 +810,7 @@ describe("Cipher DTO", () => {
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const deletedDate = new Date("2022-09-04T01:06:40.441Z");
const archivedDate = new Date("2022-10-04T01:06:40.441Z");
const actual = Cipher.fromJSON({
name: "myName",
notes: "myNotes",
@@ -801,6 +819,7 @@ describe("Cipher DTO", () => {
fields: ["field1", "field2"] as any,
passwordHistory: ["ph1", "ph2"] as any,
deletedDate: deletedDate.toISOString(),
archivedDate: archivedDate.toISOString(),
} as Jsonify<Cipher>);
expect(actual).toMatchObject({
@@ -811,6 +830,7 @@ describe("Cipher DTO", () => {
fields: ["field1_fromJSON", "field2_fromJSON"],
passwordHistory: ["ph1_fromJSON", "ph2_fromJSON"],
deletedDate: deletedDate,
archivedDate: archivedDate,
});
expect(actual).toBeInstanceOf(Cipher);
});
@@ -862,7 +882,8 @@ describe("Cipher DTO", () => {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
archivedDate: undefined,
reprompt: CipherRepromptType.None,
key: "EncryptedString",
login: {
@@ -1084,6 +1105,7 @@ describe("Cipher DTO", () => {
],
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: undefined,
archivedDate: undefined,
revisionDate: "2022-01-31T12:00:00.000Z",
};
@@ -1105,7 +1127,8 @@ describe("Cipher DTO", () => {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
archivedDate: undefined,
reprompt: CipherRepromptType.None,
key: "EncryptedString",
login: {

View File

@@ -56,7 +56,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
passwordHistory: Password[];
collectionIds: string[];
creationDate: Date;
deletedDate: Date;
deletedDate: Date | undefined;
archivedDate: Date | undefined;
reprompt: CipherRepromptType;
key: EncString;
@@ -94,7 +95,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
this.collectionIds = obj.collectionIds;
this.localData = localData;
this.creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null;
this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : null;
this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : undefined;
this.archivedDate = obj.archivedDate != null ? new Date(obj.archivedDate) : undefined;
this.reprompt = obj.reprompt;
switch (this.type) {
@@ -244,10 +246,11 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
c.type = this.type;
c.collectionIds = this.collectionIds;
c.creationDate = this.creationDate != null ? this.creationDate.toISOString() : null;
c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null;
c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : undefined;
c.reprompt = this.reprompt;
c.key = this.key?.encryptedString;
c.permissions = this.permissions;
c.archivedDate = this.archivedDate != null ? this.archivedDate.toISOString() : undefined;
this.buildDataModel(this, c, {
name: null,
@@ -296,11 +299,12 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
const notes = EncString.fromJSON(obj.notes);
const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
const deletedDate = obj.deletedDate == null ? undefined : new Date(obj.deletedDate);
const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a));
const fields = obj.fields?.map((f: any) => Field.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => Password.fromJSON(ph));
const key = EncString.fromJSON(obj.key);
const archivedDate = obj.archivedDate == null ? undefined : new Date(obj.archivedDate);
Object.assign(domain, obj, {
name,
@@ -312,6 +316,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
fields,
passwordHistory,
key,
archivedDate,
});
switch (obj.type) {
@@ -369,6 +374,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
revisionDate: this.revisionDate?.toISOString(),
creationDate: this.creationDate?.toISOString(),
deletedDate: this.deletedDate?.toISOString(),
archivedDate: this.archivedDate?.toISOString(),
reprompt: this.reprompt,
// Initialize all cipher-type-specific properties as undefined
login: undefined,
@@ -434,7 +440,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
sdkCipher.passwordHistory?.map((ph) => Password.fromSdkPasswordHistory(ph)) ?? [];
cipher.creationDate = new Date(sdkCipher.creationDate);
cipher.revisionDate = new Date(sdkCipher.revisionDate);
cipher.deletedDate = sdkCipher.deletedDate ? new Date(sdkCipher.deletedDate) : null;
cipher.deletedDate = sdkCipher.deletedDate ? new Date(sdkCipher.deletedDate) : undefined;
cipher.archivedDate = sdkCipher.archivedDate ? new Date(sdkCipher.archivedDate) : undefined;
cipher.reprompt = sdkCipher.reprompt;
// Cipher type specific properties

View File

@@ -0,0 +1,17 @@
import { CipherId } from "@bitwarden/common/types/guid";
export class CipherBulkArchiveRequest {
ids: CipherId[];
constructor(ids: CipherId[]) {
this.ids = ids == null ? [] : ids;
}
}
export class CipherBulkUnarchiveRequest {
ids: CipherId[];
constructor(ids: CipherId[]) {
this.ids = ids == null ? [] : ids;
}
}

View File

@@ -35,6 +35,7 @@ export class CipherRequest {
attachments: { [id: string]: string };
attachments2: { [id: string]: AttachmentRequest };
lastKnownRevisionDate: Date;
archivedDate: Date | null;
reprompt: CipherRepromptType;
key: string;
@@ -47,6 +48,7 @@ export class CipherRequest {
this.notes = cipher.notes ? cipher.notes.encryptedString : null;
this.favorite = cipher.favorite;
this.lastKnownRevisionDate = cipher.revisionDate;
this.archivedDate = cipher.archivedDate;
this.reprompt = cipher.reprompt;
this.key = cipher.key?.encryptedString;

View File

@@ -38,6 +38,7 @@ export class CipherResponse extends BaseResponse {
collectionIds: string[];
creationDate: string;
deletedDate: string;
archivedDate: string;
reprompt: CipherRepromptType;
key: string;
@@ -62,6 +63,7 @@ export class CipherResponse extends BaseResponse {
this.collectionIds = this.getResponseProperty("CollectionIds");
this.creationDate = this.getResponseProperty("CreationDate");
this.deletedDate = this.getResponseProperty("DeletedDate");
this.archivedDate = this.getResponseProperty("ArchivedDate");
const login = this.getResponseProperty("Login");
if (login != null) {

View File

@@ -163,6 +163,7 @@ describe("CipherView", () => {
creationDate: "2022-01-01T12:00:00.000Z",
revisionDate: "2022-01-02T12:00:00.000Z",
deletedDate: undefined,
archivedDate: undefined,
};
});

View File

@@ -49,7 +49,8 @@ export class CipherView implements View, InitializerMetadata {
collectionIds: string[] = null;
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
deletedDate: Date | null = null;
archivedDate: Date | null = null;
reprompt: CipherRepromptType = CipherRepromptType.None;
// We need a copy of the encrypted key so we can pass it to
// the SdkCipherView during encryption
@@ -79,6 +80,7 @@ export class CipherView implements View, InitializerMetadata {
this.revisionDate = c.revisionDate;
this.creationDate = c.creationDate;
this.deletedDate = c.deletedDate;
this.archivedDate = c.archivedDate;
// Old locally stored ciphers might have reprompt == null. If so set it to None.
this.reprompt = c.reprompt ?? CipherRepromptType.None;
this.key = c.key;
@@ -143,6 +145,10 @@ export class CipherView implements View, InitializerMetadata {
return this.deletedDate != null;
}
get isArchived(): boolean {
return this.archivedDate != null;
}
get linkedFieldOptions() {
return this.item?.linkedFieldOptions;
}
@@ -197,6 +203,7 @@ export class CipherView implements View, InitializerMetadata {
const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
const archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate);
const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a));
const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph));
@@ -217,6 +224,7 @@ export class CipherView implements View, InitializerMetadata {
creationDate: creationDate,
revisionDate: revisionDate,
deletedDate: deletedDate,
archivedDate: archivedDate,
attachments: attachments,
fields: fields,
passwordHistory: passwordHistory,
@@ -277,6 +285,7 @@ export class CipherView implements View, InitializerMetadata {
cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
cipherView.archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate);
cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None;
cipherView.key = EncString.fromJSON(obj.key);
@@ -330,6 +339,7 @@ export class CipherView implements View, InitializerMetadata {
revisionDate: (this.revisionDate ?? new Date()).toISOString(),
creationDate: (this.creationDate ?? new Date()).toISOString(),
deletedDate: this.deletedDate?.toISOString(),
archivedDate: this.archivedDate?.toISOString(),
reprompt: this.reprompt ?? CipherRepromptType.None,
key: this.key?.toSdk(),
// Cipher type specific properties are set in the switch statement below

View File

@@ -68,6 +68,7 @@ const cipherData: CipherData = {
deletedDate: null,
permissions: new CipherPermissionsApi(),
key: "EncKey",
archivedDate: null,
reprompt: CipherRepromptType.None,
login: {
uris: [

View File

@@ -286,6 +286,7 @@ export class CipherService implements CipherServiceAbstraction {
cipher.collectionIds = model.collectionIds;
cipher.creationDate = model.creationDate;
cipher.revisionDate = model.revisionDate;
cipher.archivedDate = model.archivedDate;
cipher.reprompt = model.reprompt;
cipher.edit = model.edit;
@@ -634,6 +635,10 @@ export class CipherService implements CipherServiceAbstraction {
);
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
const archiveFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19148_InnovationArchive,
);
return ciphers.filter((cipher) => {
const type = CipherViewLikeUtils.getType(cipher);
const login = CipherViewLikeUtils.getLogin(cipher);
@@ -643,6 +648,10 @@ export class CipherService implements CipherServiceAbstraction {
return false;
}
if (archiveFeatureEnabled && CipherViewLikeUtils.isArchived(cipher)) {
return false;
}
if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) {
return true;
}
@@ -666,8 +675,16 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted(userId);
const archiveFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19148_InnovationArchive,
);
return ciphers
.filter((cipher) => cipher.deletedDate == null && type.includes(cipher.type))
.filter(
(cipher) =>
cipher.deletedDate == null &&
(!archiveFeatureEnabled || !cipher.isArchived) &&
type.includes(cipher.type),
)
.sort((a, b) => this.sortCiphersByLastUsedThenName(a, b));
}

View File

@@ -48,7 +48,8 @@ const cipherData: CipherData = {
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
deletedDate: undefined,
archivedDate: undefined,
permissions: new CipherPermissionsApi(),
key: "EncKey",
reprompt: CipherRepromptType.None,

View File

@@ -110,6 +110,32 @@ describe("CipherViewLikeUtils", () => {
});
});
describe("isArchived", () => {
it("returns true when the cipher is archived", () => {
const cipherListView = {
id: "1",
archivedDate: "2024-02-02",
type: "identity",
} as unknown as CipherListView;
const cipherView = createCipherView();
cipherView.archivedDate = new Date();
expect(CipherViewLikeUtils.isArchived(cipherListView)).toBe(true);
expect(CipherViewLikeUtils.isArchived(cipherView)).toBe(true);
});
it("returns false when the cipher is not archived", () => {
const cipherListView = {
id: "2",
type: "identity",
} as unknown as CipherListView;
const cipherView = createCipherView();
expect(CipherViewLikeUtils.isArchived(cipherListView)).toBe(false);
expect(CipherViewLikeUtils.isArchived(cipherView)).toBe(false);
});
});
describe("isDeleted", () => {
it("returns true when the cipher is deleted", () => {
const cipherListView = { deletedDate: "2024-02-02", type: "identity" } as CipherListView;

View File

@@ -71,6 +71,15 @@ export class CipherViewLikeUtils {
return cipher.type === CipherType.Card ? cipher.card : null;
};
/** @returns `true` when the cipher has been archived, `false` otherwise. */
static isArchived = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) {
return !!cipher.archivedDate;
}
return cipher.isArchived;
};
/** @returns `true` when the cipher has been deleted, `false` otherwise. */
static isDeleted = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) {

View File

@@ -0,0 +1,14 @@
import { Observable } from "rxjs";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
export abstract class CipherArchiveService {
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract canInteract(cipher: CipherView): Promise<boolean>;
}

View File

@@ -186,6 +186,11 @@ export class ItemDetailsSectionComponent implements OnInit {
}
get showOwnership() {
// Don't show ownership field for archived ciphers
if (this.originalCipherView?.isArchived) {
return false;
}
// Show ownership field when editing with available orgs
const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit";

View File

@@ -27,3 +27,5 @@ 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 "./abstractions/cipher-archive.service";
export * from "./services/default-cipher-archive.service";

View File

@@ -0,0 +1,289 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherBulkArchiveRequest,
CipherBulkUnarchiveRequest,
} from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import { DecryptionFailureDialogComponent } from "../components/decryption-failure-dialog/decryption-failure-dialog.component";
import { DefaultCipherArchiveService } from "./default-cipher-archive.service";
import { PasswordRepromptService } from "./password-reprompt.service";
describe("DefaultCipherArchiveService", () => {
let service: DefaultCipherArchiveService;
let mockCipherService: jest.Mocked<CipherService>;
let mockApiService: jest.Mocked<ApiService>;
let mockDialogService: jest.Mocked<DialogService>;
let mockPasswordRepromptService: jest.Mocked<PasswordRepromptService>;
let mockBillingAccountProfileStateService: jest.Mocked<BillingAccountProfileStateService>;
let mockConfigService: jest.Mocked<ConfigService>;
const userId = "user-id" as UserId;
const cipherId = "123" as CipherId;
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockApiService = mock<ApiService>();
mockDialogService = mock<DialogService>();
mockPasswordRepromptService = mock<PasswordRepromptService>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockConfigService = mock<ConfigService>();
service = new DefaultCipherArchiveService(
mockCipherService,
mockApiService,
mockDialogService,
mockPasswordRepromptService,
mockBillingAccountProfileStateService,
mockConfigService,
);
});
describe("archivedCiphers$", () => {
it("should return only archived ciphers", async () => {
const mockCiphers: CipherListView[] = [
{
id: "1",
archivedDate: "2024-01-15T10:30:00.000Z",
type: "identity",
} as unknown as CipherListView,
{
id: "2",
type: "secureNote",
} as unknown as CipherListView,
{
id: "3",
archivedDate: "2024-01-15T10:30:00.000Z",
deletedDate: "2024-01-16T10:30:00.000Z",
type: "sshKey",
} as unknown as CipherListView,
];
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
const result = await firstValueFrom(service.archivedCiphers$(userId));
expect(result).toHaveLength(1);
expect(result[0].id).toEqual("1");
});
it("should return empty array when no archived ciphers exist", async () => {
const mockCiphers: CipherListView[] = [
{
id: "1",
type: "identity",
} as unknown as CipherListView,
];
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
const result = await firstValueFrom(service.archivedCiphers$(userId));
expect(result).toHaveLength(0);
});
});
describe("userCanArchive$", () => {
it("should return true when user has premium and feature flag is enabled", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
const result = await firstValueFrom(service.userCanArchive$(userId));
expect(result).toBe(true);
expect(mockBillingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId,
);
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM19148_InnovationArchive,
);
});
it("should return false when feature flag is disabled", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
const result = await firstValueFrom(service.userCanArchive$(userId));
expect(result).toBe(false);
});
});
describe("archiveWithServer", () => {
const mockResponse = {
data: [
{
id: cipherId,
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:31:00.000Z",
},
],
};
beforeEach(() => {
mockApiService.send.mockResolvedValue(mockResponse);
mockCipherService.ciphers$.mockReturnValue(
of({
[cipherId]: {
id: cipherId,
revisionDate: "2024-01-15T10:00:00.000Z",
} as any,
}),
);
mockCipherService.replace.mockResolvedValue(undefined);
});
it("should archive single cipher", async () => {
await service.archiveWithServer(cipherId, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/archive",
expect.any(CipherBulkArchiveRequest),
true,
true,
);
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
expect(mockCipherService.replace).toHaveBeenCalledWith(
expect.objectContaining({
[cipherId]: expect.objectContaining({
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:31:00.000Z",
}),
}),
userId,
);
});
it("should archive multiple ciphers", async () => {
const cipherIds = [cipherId, "cipher-id-2" as CipherId];
await service.archiveWithServer(cipherIds, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/archive",
expect.objectContaining({
ids: cipherIds,
}),
true,
true,
);
});
});
describe("unarchiveWithServer", () => {
const mockResponse = {
data: [
{
id: cipherId,
revisionDate: "2024-01-15T10:31:00.000Z",
},
],
};
beforeEach(() => {
mockApiService.send.mockResolvedValue(mockResponse);
mockCipherService.ciphers$.mockReturnValue(
of({
[cipherId]: {
id: cipherId,
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:00:00.000Z",
} as any,
}),
);
mockCipherService.replace.mockResolvedValue(undefined);
});
it("should unarchive single cipher", async () => {
await service.unarchiveWithServer(cipherId, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/unarchive",
expect.any(CipherBulkUnarchiveRequest),
true,
true,
);
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
expect(mockCipherService.replace).toHaveBeenCalledWith(
expect.objectContaining({
[cipherId]: expect.objectContaining({
revisionDate: "2024-01-15T10:31:00.000Z",
}),
}),
userId,
);
});
it("should unarchive multiple ciphers", async () => {
const cipherIds = [cipherId, "cipher-id-2" as CipherId];
await service.unarchiveWithServer(cipherIds, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/unarchive",
expect.objectContaining({
ids: cipherIds,
}),
true,
true,
);
});
});
describe("canInteract", () => {
let mockCipherView: CipherView;
beforeEach(() => {
mockCipherView = {
id: cipherId,
decryptionFailure: false,
} as unknown as CipherView;
});
it("should return false and open dialog when cipher has decryption failure", async () => {
mockCipherView.decryptionFailure = true;
const openSpy = jest.spyOn(DecryptionFailureDialogComponent, "open").mockImplementation();
const result = await service.canInteract(mockCipherView);
expect(result).toBe(false);
expect(openSpy).toHaveBeenCalledWith(mockDialogService, {
cipherIds: [cipherId],
});
});
it("should return password reprompt result when no decryption failure", async () => {
mockPasswordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
const result = await service.canInteract(mockCipherView);
expect(result).toBe(true);
expect(mockPasswordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(
mockCipherView,
);
});
it("should return false when password reprompt fails", async () => {
mockPasswordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
const result = await service.canInteract(mockCipherView);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,145 @@
import { filter, map, Observable, shareReplay, combineLatest, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherBulkArchiveRequest,
CipherBulkUnarchiveRequest,
} from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request";
import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { DialogService } from "@bitwarden/components";
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
import { DecryptionFailureDialogComponent } from "../components/decryption-failure-dialog/decryption-failure-dialog.component";
import { PasswordRepromptService } from "./password-reprompt.service";
export class DefaultCipherArchiveService implements CipherArchiveService {
constructor(
private cipherService: CipherService,
private apiService: ApiService,
private dialogService: DialogService,
private passwordRepromptService: PasswordRepromptService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
) {}
/**
* Observable that contains the list of ciphers that have been archived.
*/
archivedCiphers$(userId: UserId): Observable<CipherViewLike[]> {
return this.cipherService.cipherListViews$(userId).pipe(
filter((cipher) => cipher != null),
map((ciphers) =>
ciphers.filter(
(cipher) =>
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher),
),
),
);
}
/**
* User can archive items if:
* Feature Flag is enabled
* User has premium from any source (personal or organization)
*/
userCanArchive$(userId: UserId): Observable<boolean> {
return combineLatest([
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive),
]).pipe(
map(([hasPremium, archiveFlagEnabled]) => hasPremium && archiveFlagEnabled),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
/**
* User can access the archive vault if:
* Feature Flag is enabled
* There is at least one archived item
* ///////////// NOTE /////////////
* This is separated from userCanArchive because a user that loses premium status, but has archived items,
* should still be able to access their archive vault. The items will be read-only, and can be restored.
*/
showArchiveVault$(userId: UserId): Observable<boolean> {
return combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive),
this.archivedCiphers$(userId),
]).pipe(
map(
([archiveFlagEnabled, hasArchivedItems]) =>
archiveFlagEnabled && hasArchivedItems.length > 0,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
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));
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.replace(currentCiphers, userId);
}
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
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));
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.replace(currentCiphers, userId);
}
/**
* Check if the user is able to interact with the cipher
* (password re-prompt / decryption failure checks).
* @param cipher
* @private
*/
async canInteract(cipher: CipherView) {
if (cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
return false;
}
return await this.passwordRepromptService.passwordRepromptCheck(cipher);
}
}

8
package-lock.json generated
View File

@@ -23,7 +23,7 @@
"@angular/platform-browser": "19.2.14",
"@angular/platform-browser-dynamic": "19.2.14",
"@angular/router": "19.2.14",
"@bitwarden/sdk-internal": "0.2.0-main.266",
"@bitwarden/sdk-internal": "0.2.0-main.296",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
@@ -4688,9 +4688,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.266",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.266.tgz",
"integrity": "sha512-2Axa1D9AEkax2ssqahZYHVkk2RdguzLV2bJ6j99AZhh4qjGIYtDvmc5gDh7zhuw7Ig7H3mNpKwCZ/eJgadyH6g==",
"version": "0.2.0-main.296",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.296.tgz",
"integrity": "sha512-SDTWRwnR+KritfgJVBgWKd27TJxl4IlUdTldVJ/tA0qM5OqGWrY6s4ubtl5eaGIl2X4WYRAvpe+VR93FLakk6A==",
"license": "GPL-3.0",
"dependencies": {
"type-fest": "^4.41.0"

View File

@@ -158,7 +158,7 @@
"@angular/platform-browser": "19.2.14",
"@angular/platform-browser-dynamic": "19.2.14",
"@angular/router": "19.2.14",
"@bitwarden/sdk-internal": "0.2.0-main.266",
"@bitwarden/sdk-internal": "0.2.0-main.296",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",