From 0e90e91f679ce94df6e7147c3e6c0fcdfa8df981 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 20 Mar 2025 16:46:18 -0700 Subject: [PATCH] [PM-19406] Archive item actions Browser (#13933) * [PM-19406] Cipher service changes * [PM-19406] Wire up archive/unarchive actions --- apps/browser/src/_locales/en/messages.json | 12 +++++ .../item-more-options.component.html | 3 ++ .../item-more-options.component.ts | 43 +++++++++++++++++- .../vault/popup/settings/archive.component.ts | 9 +++- .../src/vault/abstractions/cipher.service.ts | 2 + .../request/cipher-bulk-archive.request.ts | 17 +++++++ .../src/vault/services/cipher.service.ts | 44 +++++++++++++++++++ 7 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 libs/common/src/vault/models/request/cipher-bulk-archive.request.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bd2bd917a9d..79169a71dc9 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -559,6 +559,18 @@ "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/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 6e6e30b359b..86188915796 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 @@ -35,5 +35,8 @@ {{ "assignToCollections" | 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 94b4c2b855b..c2bfc6b845f 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,14 +3,17 @@ import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, Input, OnInit } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; -import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, of, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -72,6 +75,24 @@ export class ItemMoreOptionsComponent implements OnInit { switchMap((c) => this.cipherAuthorizationService.canCloneCipher$(c)), ); + /** + * Observable that emits a boolean value indicating if the user is authorized to archive the cipher. + * @protected + */ + protected canArchive$ = this.configService + .getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive) + .pipe( + switchMap((enabled) => { + if (!enabled) { + return of(false); + } + return this._cipher$.pipe( + filter((c) => c != null), + map((c) => !c.isArchived && c.organizationId == null), + ); + }), + ); + /** Boolean dependent on the current user having access to an organization */ protected hasOrganizations = false; @@ -86,6 +107,7 @@ export class ItemMoreOptionsComponent implements OnInit { private accountService: AccountService, private organizationService: OrganizationService, private cipherAuthorizationService: CipherAuthorizationService, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -196,4 +218,23 @@ export class ItemMoreOptionsComponent implements OnInit { 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.cipherService.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/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index 8114f51ab4c..ad463cea2d0 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -118,7 +118,14 @@ export class ArchiveComponent { if (!(await this.canInteract(cipher))) { return; } - // TODO: Implement once endpoint is available + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + await this.cipherService.unarchiveWithServer(cipher.id as CipherId, activeUserId); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemRemovedFromArchive"), + }); } async clone(cipher: CipherView) { diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 1e4275ff89b..d5b3081c770 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -197,6 +197,8 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract restoreManyWithServer(ids: string[], orgId?: string): Promise; + abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise; abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise; /** 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/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 0217ffe932c..08608f3121d 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -14,6 +14,10 @@ import { } from "rxjs"; import { SemVer } from "semver"; +import { + CipherBulkArchiveRequest, + CipherBulkUnarchiveRequest, +} from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request"; import { KeyService } from "@bitwarden/key-management"; import { ApiService } from "../../abstractions/api.service"; @@ -1313,6 +1317,46 @@ export class CipherService implements CipherServiceAbstraction { await this.restore({ id: id, revisionDate: response.revisionDate }, userId); } + 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); + + await this.updateEncryptedCipherState((ciphers) => { + for (const cipher of response.data) { + const localCipher = ciphers[cipher.id as CipherId]; + + if (localCipher == null) { + continue; + } + + localCipher.archivedDate = cipher.archivedDate; + localCipher.revisionDate = cipher.revisionDate; + } + return ciphers; + }, 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); + + await this.updateEncryptedCipherState((ciphers) => { + for (const cipher of response.data) { + const localCipher = ciphers[cipher.id as CipherId]; + + if (localCipher == null) { + continue; + } + + localCipher.archivedDate = cipher.archivedDate; + localCipher.revisionDate = cipher.revisionDate; + } + return ciphers; + }, userId); + } + /** * No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable * The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore