diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index eb8732d7d91..72c3892af62 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 251a86ef80b..501ef0ba8ff 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -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, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 8da94acdd7a..d87c9417c85 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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({ diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 42e2779679a..16cc04ce612 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -38,5 +38,10 @@ {{ "assignToCollections" | i18n }} + @if (canArchive$ | async) { + + {{ "archive" | i18n }} + + } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 9ee3c3a6e41..6979f519f2d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -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"), + }); + } } diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 3ba4e832b02..6499719b64f 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -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; + let activeUserLastSync$: BehaviorSubject; let viewCacheService: { signal: jest.Mock; mockSignal: WritableSignal; @@ -64,6 +66,9 @@ describe("VaultPopupItemsService", () => { const inlineMenuFieldQualificationServiceMock = mock(); const userId = Utils.newGuid() as UserId; const accountServiceMock = mockAccountServiceWith(userId); + const configServiceMock = mock(); + const cipherArchiveServiceMock = mock(); + cipherArchiveServiceMock.userCanArchive$.mockReturnValue(of(true)); const restrictedItemTypesService = { restricted$: new BehaviorSubject([]), @@ -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(new Date()); syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$); + configServiceMock.getFeatureFlag$.mockReturnValue(of(true)); const testSearchSignal = createMockSignal(""); 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[]), ); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 9d44eef2e47..3e4b793737e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -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 = 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) { diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html new file mode 100644 index 00000000000..5fb57814fff --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -0,0 +1,81 @@ + + + + + + + + @if (archivedCiphers$ | async; as archivedItems) { + @if (archivedItems.length) { + + + + {{ "itemsInArchive" | i18n }} + + {{ archivedItems.length }} + + + @for (cipher of archivedItems; track cipher.id) { + + + + + + {{ cipher.name }} + @if (cipher.hasAttachments) { + + } + {{ cipher.subTitle }} + + + + + + {{ "edit" | i18n }} + + + {{ "clone" | i18n }} + + + {{ "unarchive" | i18n }} + + + + {{ "delete" | i18n }} + + + + + + } + + + } @else { + + + {{ "noItemsInArchive" | i18n }} + + + {{ "noItemsInArchiveDesc" | i18n }} + + + } + } + diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts new file mode 100644 index 00000000000..c3e078a9274 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -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 = 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, + }, + }); + } +} diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index 4e16f58d7f8..630c55d0038 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -34,6 +34,14 @@ + @if (userCanArchive() || showArchiveFilter()) { + + + {{ "archive" | i18n }} + + + + } {{ "trash" | i18n }} diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index e7305d57cab..4e8a49b2591 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -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() { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index adc62e3e4ed..aa597a6bf97 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -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?" } } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index bf0df14c8c6..1ab76c74655 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -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, ); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index aa54bdd31bf..7aa1d441589 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -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 { @@ -248,11 +251,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }; async buildAllFilters(): Promise { + 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 { + 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) => Promise, + }; + return archiveFilterSection; + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts index 25874d683be..936dfb0e675 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts @@ -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( + { id: "archive", name: "", type: "archive", icon: "" }, + null, + ); } else if (filter.type !== undefined && filter.type !== "trash") { legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject( cipherTypeTree, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index 7fdd0804c08..c15dd51a969 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -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; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts index 02d536eb6ab..f9a80791030 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts @@ -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; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts index 280ffd15732..13f1ed7b1a3 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts @@ -15,6 +15,7 @@ const itemTypes = [ "identity", "note", "sshKey", + "archive", "trash", All, ] as const; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts index 4210a6c8129..f1e6222b57a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts @@ -21,6 +21,7 @@ export const VaultFilterLabel = { TypeFilter: "typeFilter", FolderFilter: "folderFilter", CollectionFilter: "collectionFilter", + ArchiveFilter: "archiveFilter", TrashFilter: "trashFilter", } as const; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts index 7f001f3aab2..4617102cebe 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts @@ -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; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts index 9259dd08114..7a92db6a381 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts @@ -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 & { diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 216564e6254..63e6d391b42 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -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()}`; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 0b2960fb8e1..958cf655d17 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -69,14 +69,18 @@ *ngIf="isEmpty && !performingInitialLoad" > - {{ "noItemsInList" | i18n }} + {{ "noItemsInArchive" | i18n }} + + {{ "archivedItemsDescription" | i18n }} + + {{ "noItemsInList" | i18n }} {{ "newItem" | i18n }} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index acb41a66949..01082bfcd60 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -77,6 +77,7 @@ import { AttachmentDialogCloseResult, AttachmentDialogResult, AttachmentsV2Component, + CipherArchiveService, CipherFormConfig, CollectionAssignmentResult, DecryptionFailureDialogComponent, @@ -183,6 +184,13 @@ export class VaultComponent 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 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 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]; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 14fc9fa29e8..25bc8bd753c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 732609293ae..f4915a06d56 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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({ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7c0b761438b..a61045a362f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index 846a56dac85..0861bd39d61 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -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; } } diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 7554f23f6a0..4921cce8df2 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -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; diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 8afd6824379..c2cb99740db 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -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(); const encryptService = mock(); @@ -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); 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: { diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 7364930e29c..b80f38f66af 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -56,7 +56,8 @@ export class Cipher extends Domain implements Decryptable { 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 { 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 { 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 { 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 { fields, passwordHistory, key, + archivedDate, }); switch (obj.type) { @@ -369,6 +374,7 @@ export class Cipher extends Domain implements Decryptable { 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 { 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 diff --git a/libs/common/src/vault/models/request/cipher-bulk-archive.request.ts b/libs/common/src/vault/models/request/cipher-bulk-archive.request.ts new file mode 100644 index 00000000000..a75ab632b3a --- /dev/null +++ b/libs/common/src/vault/models/request/cipher-bulk-archive.request.ts @@ -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; + } +} diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 2e3b2efbedc..63776c8aea6 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -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; diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index d4c907ae2b0..bf01e0f08de 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -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) { diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index 5dbdfdbbef2..e9614db6858 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -163,6 +163,7 @@ describe("CipherView", () => { creationDate: "2022-01-01T12:00:00.000Z", revisionDate: "2022-01-02T12:00:00.000Z", deletedDate: undefined, + archivedDate: undefined, }; }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 015d2585850..d52e6eb11bd 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -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 diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index e7437b24656..d8e98c75d40 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -68,6 +68,7 @@ const cipherData: CipherData = { deletedDate: null, permissions: new CipherPermissionsApi(), key: "EncKey", + archivedDate: null, reprompt: CipherRepromptType.None, login: { uris: [ diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 7373bca2831..6504fd1a97d 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -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 { 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)); } diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index dee45d46a57..6d6341bd1fa 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -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, diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts index 0df44e67f1b..70122ebd27b 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts @@ -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; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 5cb4a7a084e..04adb8d4832 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -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)) { diff --git a/libs/vault/src/abstractions/cipher-archive.service.ts b/libs/vault/src/abstractions/cipher-archive.service.ts new file mode 100644 index 00000000000..6240e4001c8 --- /dev/null +++ b/libs/vault/src/abstractions/cipher-archive.service.ts @@ -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; + abstract userCanArchive$(userId: UserId): Observable; + abstract showArchiveVault$(userId: UserId): Observable; + abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract canInteract(cipher: CipherView): Promise; +} diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 978675e6ad9..6af2fa19e26 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -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"; diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index efaefc77ade..5acac9ec009 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -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"; diff --git a/libs/vault/src/services/default-cipher-archive.service.spec.ts b/libs/vault/src/services/default-cipher-archive.service.spec.ts new file mode 100644 index 00000000000..ec2943ce7e4 --- /dev/null +++ b/libs/vault/src/services/default-cipher-archive.service.spec.ts @@ -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; + let mockApiService: jest.Mocked; + let mockDialogService: jest.Mocked; + let mockPasswordRepromptService: jest.Mocked; + let mockBillingAccountProfileStateService: jest.Mocked; + let mockConfigService: jest.Mocked; + + const userId = "user-id" as UserId; + const cipherId = "123" as CipherId; + + beforeEach(() => { + mockCipherService = mock(); + mockApiService = mock(); + mockDialogService = mock(); + mockPasswordRepromptService = mock(); + mockBillingAccountProfileStateService = mock(); + mockConfigService = mock(); + + 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); + }); + }); +}); diff --git a/libs/vault/src/services/default-cipher-archive.service.ts b/libs/vault/src/services/default-cipher-archive.service.ts new file mode 100644 index 00000000000..d9a0ec54d73 --- /dev/null +++ b/libs/vault/src/services/default-cipher-archive.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/package-lock.json b/package-lock.json index 3bd9968d50d..2ea907ec598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index e93db03fe9f..65ead3fffc5 100644 --- a/package.json +++ b/package.json @@ -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",
+ {{ "archivedItemsDescription" | i18n }} +