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) { + + } 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) { + + + + + + + + + + + + + } + +
+ } @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 }}