1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-21 11:53:34 +00:00

[PM-24560] - Add Archive UI Element to View and Edit Item Cards (#16954)

* finalize new UI elements for archive/unarchive

* add tests

* add missing service

* add tests

* updates to edit and view pages

* use structureClone

* fix lint

* fix typo

* clean up return types

* fixes to archive UI

* fix tests

* use @if and userId$
This commit is contained in:
Jordan Aasen
2026-01-09 16:39:22 -08:00
committed by GitHub
parent 1714660bde
commit 404d925f84
17 changed files with 824 additions and 122 deletions

View File

@@ -3,12 +3,14 @@ import { Observable } from "rxjs";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherData } from "../models/data/cipher.data";
export abstract class CipherArchiveService {
abstract hasArchiveFlagEnabled$: Observable<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract userHasPremium$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
}

View File

@@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata {
return this.item?.subTitle;
}
get canBeArchived(): boolean {
return !this.isDeleted && !this.isArchived;
}
get hasPasswordHistory(): boolean {
return this.passwordHistory && this.passwordHistory.length > 0;
}

View File

@@ -1,3 +1,7 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { of, firstValueFrom, BehaviorSubject } from "rxjs";

View File

@@ -18,6 +18,7 @@ import {
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
import { CipherData } from "../models/data/cipher.data";
export class DefaultCipherArchiveService implements CipherArchiveService {
constructor(
@@ -84,15 +85,17 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
);
}
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
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));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
@@ -102,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(currentCiphers), userId);
await this.cipherService.upsert(Object.values(localCiphers), userId);
return response.data[0];
}
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
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));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
@@ -123,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(currentCiphers), userId);
await this.cipherService.upsert(Object.values(localCiphers), userId);
return response.data[0];
}
}

View File

@@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => {
const mockCipher = new CipherView();
mockCipher.id = "cipher-id" as CipherId;
const mockUserId = "user-id";
const mockCipherData = { id: mockCipher.id } as CipherData;
beforeEach(() => {
cipherArchiveService = mock<CipherArchiveService>();
@@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => {
dialogService.openSimpleDialog.mockResolvedValue(true);
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
cipherArchiveService.archiveWithServer.mockResolvedValue(undefined);
cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined);
cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData);
cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData);
i18nService.t.mockImplementation((key) => key);
service = new ArchiveCipherUtilitiesService(

View File

@@ -25,11 +25,18 @@ export class ArchiveCipherUtilitiesService {
private accountService: AccountService,
) {}
/** Archive a cipher, with confirmation dialog and password reprompt checks. */
async archiveCipher(cipher: CipherView) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
/** Archive a cipher, with confirmation dialog and password reprompt checks.
*
* @param cipher The cipher to archive
* @param skipReprompt Whether to skip the password reprompt check
* @returns The archived CipherData on success, or undefined on failure or cancellation
*/
async archiveCipher(cipher: CipherView, skipReprompt = false) {
if (!skipReprompt) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
}
const confirmed = await this.dialogService.openSimpleDialog({
@@ -43,38 +50,47 @@ export class ArchiveCipherUtilitiesService {
}
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherArchiveService
.archiveWithServer(cipher.id as CipherId, userId)
.then(() => {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasSentToArchive"),
});
})
.catch(() => {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
try {
const cipherResponse = await this.cipherArchiveService.archiveWithServer(
cipher.id as CipherId,
userId,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasSentToArchive"),
});
return cipherResponse;
} catch {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
}
/** Unarchives a cipher */
/** Unarchives a cipher
* @param cipher The cipher to unarchive
* @returns The unarchived cipher on success, or undefined on failure
*/
async unarchiveCipher(cipher: CipherView) {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherArchiveService
.unarchiveWithServer(cipher.id as CipherId, userId)
.then(() => {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasUnarchived"),
});
})
.catch(() => {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
try {
const cipherResponse = await this.cipherArchiveService.unarchiveWithServer(
cipher.id as CipherId,
userId,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasUnarchived"),
});
return cipherResponse;
} catch {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
}
}