diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 16f462b7f17..bd2bd917a9d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -544,6 +544,21 @@ "searchVault": { "message": "Search vault" }, + "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." + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e73f56fa2f6..62bac9c51c3 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -21,11 +21,13 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, DevicesIcon, + DeviceVerificationIcon, LockIcon, LoginComponent, LoginDecryptionOptionsComponent, LoginSecondaryContentComponent, LoginViaAuthRequestComponent, + NewDeviceVerificationComponent, PasswordHintComponent, RegistrationFinishComponent, RegistrationLockAltIcon, @@ -35,11 +37,9 @@ import { RegistrationUserAddIcon, SetPasswordJitComponent, SsoComponent, - TwoFactorTimeoutIcon, TwoFactorAuthComponent, TwoFactorAuthGuard, - NewDeviceVerificationComponent, - DeviceVerificationIcon, + TwoFactorTimeoutIcon, UserLockIcon, VaultIcon, } from "@bitwarden/auth/angular"; @@ -90,6 +90,7 @@ import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/v import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; +import { ArchiveComponent } from "../vault/popup/settings/archive.component"; import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component"; import { TrashComponent } from "../vault/popup/settings/trash.component"; import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; @@ -691,6 +692,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, + { + path: "archive", + component: ArchiveComponent, + canActivate: [authGuard], + data: { elevation: 2 } satisfies RouteDataProperties, + }, ]; @Injectable() 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 52cb393c684..980b901b237 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,7 +1,7 @@ -import { WritableSignal, signal } from "@angular/core"; -import { TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing"; +import { signal, WritableSignal } from "@angular/core"; +import { discardPeriodicTasks, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -9,9 +9,10 @@ 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 { 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 { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -36,7 +37,7 @@ describe("VaultPopupItemsService", () => { let mockOrg: Organization; let mockCollections: CollectionView[]; - let activeUserLastSync$: BehaviorSubject; + let activeUserLastSync$: BehaviorSubject; let viewCacheService: { signal: jest.Mock; mockSignal: WritableSignal; @@ -57,6 +58,7 @@ describe("VaultPopupItemsService", () => { const inlineMenuFieldQualificationServiceMock = mock(); const userId = Utils.newGuid() as UserId; const accountServiceMock = mockAccountServiceWith(userId); + const configServiceMock = mock(); beforeEach(() => { allCiphers = cipherFactory(10); @@ -87,7 +89,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(c.id)), ); @@ -128,8 +130,9 @@ describe("VaultPopupItemsService", () => { organizationServiceMock.organizations$.mockReturnValue(new BehaviorSubject([mockOrg])); collectionService.decryptedCollections$ = 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 = { @@ -154,6 +157,7 @@ describe("VaultPopupItemsService", () => { useValue: inlineMenuFieldQualificationServiceMock, }, { provide: PopupViewCacheService, useValue: viewCacheService }, + { provide: ConfigService, useValue: configServiceMock }, ], }); @@ -277,7 +281,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); }); }); @@ -366,11 +370,11 @@ describe("VaultPopupItemsService", () => { }); }); - it("should return true when all ciphers are deleted", (done) => { + it("should return true when all ciphers are deleted/archived", (done) => { cipherServiceMock.getAllDecrypted.mockResolvedValue([ { 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[]); service.emptyVault$.subscribe((empty) => { 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 dac6a141d41..a6e686fb468 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 @@ -24,6 +24,8 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -108,20 +110,31 @@ export class VaultPopupItemsService { this.cipherService.failedToDecryptCiphers$(userId), ]), ), - map(([ciphers, failedToDecryptCiphers]) => [...failedToDecryptCiphers, ...ciphers]), + map(([ciphers, failedToDecryptCiphers]) => [ + ...(failedToDecryptCiphers ?? []), + ...(ciphers ?? []), + ]), ), ), shareReplay({ refCount: true, bufferSize: 1 }), ); + private archiveFeatureFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.PM19148_InnovationArchive, + ); + private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => - combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( - map(([organizations, collections]) => { + combineLatest([ + this.organizations$, + this.collectionService.decryptedCollections$, + this.archiveFeatureFlag$, + ]).pipe( + map(([organizations, collections, archiveFeatureEnabled]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); return ciphers - .filter((c) => !c.isDeleted) + .filter((c) => !c.isDeleted && (!archiveFeatureEnabled || !c.isArchived)) .map( (cipher) => new PopupCipherView( @@ -295,6 +308,21 @@ export class VaultPopupItemsService { shareReplay({ refCount: false, bufferSize: 1 }), ); + /** + * Observable that contains the list of ciphers that have been archived. + */ + archivedCiphers$: Observable = this._allDecryptedCiphers$.pipe( + map((ciphers) => { + return ( + ciphers + .filter((cipher) => cipher.isArchived && !cipher.isDeleted) + // Archived ciphers are individual only and never belong to an organization/collection + .map((cipher) => new PopupCipherView(cipher)) + ); + }), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + constructor( private cipherService: CipherService, private vaultSettingsService: VaultSettingsService, @@ -306,6 +334,7 @@ export class VaultPopupItemsService { private syncService: SyncService, private accountService: AccountService, private ngZone: NgZone, + private configService: ConfigService, ) {} 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..8c5f2b60220 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -0,0 +1,82 @@ + + + + + + + + @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..8114f51ab4c --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -0,0 +1,167 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } 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 } 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, + DecryptionFailureDialogComponent, + PasswordRepromptService, +} 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"; +import { VaultPopupItemsService } from "../services/vault-popup-items.service"; + +@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 vaultPopupItemsService = inject(VaultPopupItemsService); + private dialogService = inject(DialogService); + private passwordRepromptService = inject(PasswordRepromptService); + private router = inject(Router); + private cipherService = inject(CipherService); + private accountService = inject(AccountService); + private logService = inject(LogService); + private toastService = inject(ToastService); + private i18nService = inject(I18nService); + + protected archivedCiphers$ = this.vaultPopupItemsService.archivedCiphers$; + + async view(cipher: CipherView) { + if (!(await this.canInteract(cipher))) { + return; + } + + await this.router.navigate(["/view-cipher"], { + queryParams: { cipherId: cipher.id, type: cipher.type }, + }); + } + + async edit(cipher: CipherView) { + if (!(await this.canInteract(cipher))) { + return; + } + + await this.router.navigate(["/edit-cipher"], { + queryParams: { cipherId: cipher.id, type: cipher.type }, + }); + } + + async delete(cipher: CipherView) { + if (!(await this.canInteract(cipher, true))) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { key: "deleteItemConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + 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.canInteract(cipher))) { + return; + } + // TODO: Implement once endpoint is available + } + + async clone(cipher: CipherView) { + if (!(await this.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, + }, + }); + } + + /** + * Check if the user is able to interact with the cipher + * (password re-prompt / decryption failure checks). + * @param cipher + * @param ignoreDecryptionFailure - If true, the decryption failure check will be ignored. + * @private + */ + private async canInteract(cipher: CipherView, ignoreDecryptionFailure = false) { + if (cipher.decryptionFailure) { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [cipher.id as CipherId], + }); + return false; + } + + return await this.passwordRepromptService.passwordRepromptCheck(cipher); + } +} 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 03dd1182fbb..9e910a7ec11 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 @@ -24,6 +24,12 @@ + + + {{ "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 c969f0436df..1ac7e3cbae8 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 @@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; @@ -10,7 +11,6 @@ import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; -import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -22,7 +22,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co JslibModule, RouterModule, PopupPageComponent, - PopupFooterComponent, PopupHeaderComponent, PopOutComponent, ItemModule, @@ -73,4 +72,6 @@ export class VaultSettingsV2Component implements OnInit { this.lastSync = this.i18nService.t("never"); } } + + protected readonly FeatureFlag = FeatureFlag; } diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 47cf948b422..d18a57f5b8b 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -39,7 +39,7 @@ export class CipherData { passwordHistory?: PasswordHistoryData[]; collectionIds?: string[]; creationDate: string; - deletedDate: string; + deletedDate: string | null; archivedDate: string | null; reprompt: CipherRepromptType; key: string; diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 588e5017d52..d48f3677b86 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -53,8 +53,8 @@ export class Cipher extends Domain implements Decryptable { passwordHistory: Password[]; collectionIds: string[]; creationDate: Date; - deletedDate: Date; - archivedDate: Date; + deletedDate: Date | null; + archivedDate: Date | null; reprompt: CipherRepromptType; key: EncString; diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 5b77ee7508e..e38e01f6256 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -33,6 +33,7 @@ export class CipherRequest { attachments: { [id: string]: string }; attachments2: { [id: string]: AttachmentRequest }; lastKnownRevisionDate: Date; + archivedDate: Date | null; reprompt: CipherRepromptType; key: string; @@ -44,6 +45,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/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 8f778303cc8..2f5a797bda6 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -45,8 +45,8 @@ export class CipherView implements View, InitializerMetadata { collectionIds: string[] = null; revisionDate: Date = null; creationDate: Date = null; - deletedDate: Date = null; - archivedDate: Date = null; + deletedDate: Date | null = null; + archivedDate: Date | null = null; reprompt: CipherRepromptType = CipherRepromptType.None; /** @@ -194,6 +194,7 @@ export class CipherView implements View, InitializerMetadata { const view = new CipherView(); 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)); @@ -201,6 +202,7 @@ export class CipherView implements View, InitializerMetadata { Object.assign(view, obj, { revisionDate: revisionDate, deletedDate: deletedDate, + archivedDate: archivedDate, attachments: attachments, fields: fields, passwordHistory: passwordHistory, diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d774277c4a0..0217ffe932c 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -536,6 +536,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 cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null; @@ -543,6 +547,10 @@ export class CipherService implements CipherServiceAbstraction { return false; } + if (archiveFeatureEnabled && cipher.isArchived) { + return false; + } + if ( Array.isArray(includeOtherTypes) && includeOtherTypes.includes(cipher.type) && @@ -564,8 +572,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)); }