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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
81
apps/browser/src/vault/popup/settings/archive.component.html
Normal file
81
apps/browser/src/vault/popup/settings/archive.component.html
Normal 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>
|
||||
159
apps/browser/src/vault/popup/settings/archive.component.ts
Normal file
159
apps/browser/src/vault/popup/settings/archive.component.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const itemTypes = [
|
||||
"identity",
|
||||
"note",
|
||||
"sshKey",
|
||||
"archive",
|
||||
"trash",
|
||||
All,
|
||||
] as const;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const VaultFilterLabel = {
|
||||
TypeFilter: "typeFilter",
|
||||
FolderFilter: "folderFilter",
|
||||
CollectionFilter: "collectionFilter",
|
||||
ArchiveFilter: "archiveFilter",
|
||||
TrashFilter: "trashFilter",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -163,6 +163,7 @@ describe("CipherView", () => {
|
||||
creationDate: "2022-01-01T12:00:00.000Z",
|
||||
revisionDate: "2022-01-02T12:00:00.000Z",
|
||||
deletedDate: undefined,
|
||||
archivedDate: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,7 @@ const cipherData: CipherData = {
|
||||
deletedDate: null,
|
||||
permissions: new CipherPermissionsApi(),
|
||||
key: "EncKey",
|
||||
archivedDate: null,
|
||||
reprompt: CipherRepromptType.None,
|
||||
login: {
|
||||
uris: [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
14
libs/vault/src/abstractions/cipher-archive.service.ts
Normal file
14
libs/vault/src/abstractions/cipher-archive.service.ts
Normal 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>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
289
libs/vault/src/services/default-cipher-archive.service.spec.ts
Normal file
289
libs/vault/src/services/default-cipher-archive.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
145
libs/vault/src/services/default-cipher-archive.service.ts
Normal file
145
libs/vault/src/services/default-cipher-archive.service.ts
Normal 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
8
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user