1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-19152] Archive in Web (#16686)

* archive and unarchive an individual item

* bulk archive and unachive

* updates to text strings for archive empty state and tooltips

* update translation keys to have an archive verb and noun differentiation

* if premium member loses premium and has archive items. apply filter changes, and item more option changes

* updating unArchive text

* unarchive an archived item on edit if user loses premium

* updates for unarchive btn, refactor archive flag for less churn

* add services to cipher form stories

* add refresh to archive calls in vault, update bulk archive copy

* Do not show archive ability for deleted items

* add archive check for login menu actions

* remove assign to collections for archive filter

* update bulk success message

* add error handling for archive methods

* fix null reference check

* add unarchive icon

---------

Co-authored-by: Nick Krantz <nick@livefront.com>
This commit is contained in:
Jason Ng
2025-10-14 17:41:05 -04:00
committed by GitHub
parent 48c466436e
commit 98af7a13ed
19 changed files with 414 additions and 34 deletions

View File

@@ -4,6 +4,7 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
export abstract class CipherArchiveService {
abstract hasArchiveFlagEnabled$(): Observable<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract showArchiveVault$(userId: UserId): Observable<boolean>;

View File

@@ -27,6 +27,10 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
private configService: ConfigService,
) {}
hasArchiveFlagEnabled$(): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive);
}
/**
* Observable that contains the list of ciphers that have been archived.
*/

View File

@@ -10,7 +10,7 @@ import {
moduleMetadata,
StoryObj,
} from "@storybook/angular";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
// 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
@@ -155,6 +155,20 @@ export default {
} as NudgeStatus),
},
},
{
provide: CipherArchiveService,
useValue: {
userCanArchive$: of(false),
},
},
{
provide: AccountService,
useValue: {
activeAccount$: of({
name: "User 1",
}),
} as Partial<AccountService>,
},
{
provide: CipherFormService,
useClass: TestAddEditFormService,

View File

@@ -2,14 +2,18 @@ import { ChangeDetectorRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
import { CipherFormService } from "../abstractions/cipher-form.service";
@@ -23,6 +27,10 @@ describe("CipherFormComponent", () => {
const decryptCipher = jest.fn().mockResolvedValue(new CipherView());
const mockAccountService = mock<AccountService>();
const mockCipherArchiveService = mock<CipherArchiveService>();
const mockAddEditFormService = { saveCipher: jest.fn(), decryptCipher };
beforeEach(async () => {
decryptCipher.mockClear();
@@ -32,13 +40,15 @@ describe("CipherFormComponent", () => {
{ provide: ChangeDetectorRef, useValue: {} },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: ToastService, useValue: { showToast: jest.fn() } },
{ provide: CipherFormService, useValue: { saveCipher: jest.fn(), decryptCipher } },
{ provide: CipherFormService, useValue: mockAddEditFormService },
{
provide: CipherFormCacheService,
useValue: { init: jest.fn(), getCachedCipherView: jest.fn() },
},
{ provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: AccountService, useValue: mockAccountService },
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
],
}).compileComponents();
});
@@ -53,6 +63,29 @@ describe("CipherFormComponent", () => {
expect(component).toBeTruthy();
});
describe("submit", () => {
beforeEach(() => {
component.config = { mode: "edit" } as CipherFormConfig;
component["updatedCipherView"] = new CipherView();
component["updatedCipherView"].archivedDate = new Date();
});
it("should remove archivedDate when user cannot archive and cipher is archived", async () => {
mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account);
mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false));
mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView());
const originalArchivedDate = component["updatedCipherView"]?.archivedDate;
expect(originalArchivedDate).not.toBeNull();
await component.submit();
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
expect(mockCipherArchiveService.userCanArchive$).toHaveBeenCalledWith("user-id");
});
});
describe("website", () => {
it("should return null if updatedCipherView is null", () => {
component["updatedCipherView"] = null as any;

View File

@@ -17,9 +17,12 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { BehaviorSubject, Subject } from "rxjs";
import { BehaviorSubject, firstValueFrom, Subject, switchMap } from "rxjs";
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 { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -301,6 +304,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
private i18nService: I18nService,
private changeDetectorRef: ChangeDetectorRef,
private cipherFormCacheService: CipherFormCacheService,
private cipherArchiveService: CipherArchiveService,
private accountService: AccountService,
) {}
/**
@@ -342,6 +347,18 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
}
}
const userCanArchive = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
),
);
// If the item is archived but user has lost archive permissions, unarchive the item.
if (!userCanArchive && this.updatedCipherView.archivedDate) {
this.updatedCipherView.archivedDate = null;
}
const savedCipher = await this.addEditFormService.saveCipher(
this.updatedCipherView,
this.config,