1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 00:53:22 +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 jaasen-livefront
parent 246a549f58
commit 348ed4b616
17 changed files with 824 additions and 122 deletions

View File

@@ -2,7 +2,8 @@
<span bitDialogTitle aria-live="polite">
{{ title }}
</span>
@if (cipherIsArchived) {
@if (isCipherArchived) {
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
}
@@ -83,8 +84,28 @@
</button>
}
@if (showDelete) {
@if (showActionButtons) {
<div class="tw-ml-auto">
@if (userCanArchive$ | async) {
@if (isCipherArchived) {
<button
type="button"
class="tw-mr-1"
[bitAction]="unarchive"
bitIconButton="bwi-unarchive"
[label]="'unArchive' | i18n"
></button>
}
@if (cipher.canBeArchived) {
<button
type="button"
class="tw-mr-1"
[bitAction]="archive"
bitIconButton="bwi-archive"
[label]="'archiveVerb' | i18n"
></button>
}
}
<button
bitIconButton="bwi-trash"
type="button"

View File

@@ -1,22 +1,32 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { ActivatedRoute, Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
@@ -33,7 +43,13 @@ class TestVaultItemDialogComponent extends VaultItemDialogComponent {
this.params = params;
}
setTestCipher(cipher: any) {
this.cipher = cipher;
this.cipher = {
...cipher,
login: {
uris: [],
},
card: {},
};
}
setTestFormConfig(formConfig: any) {
this.formConfig = formConfig;
@@ -72,12 +88,23 @@ describe("VaultItemDialogComponent", () => {
{ provide: DIALOG_DATA, useValue: { ...baseParams } },
{ provide: DialogRef, useValue: {} },
{ provide: DialogService, useValue: {} },
{ provide: ToastService, useValue: {} },
{
provide: ToastService,
useValue: {
showToast: () => {},
},
},
{ provide: MessagingService, useValue: {} },
{ provide: LogService, useValue: {} },
{ provide: CipherService, useValue: {} },
{ provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } },
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } },
{
provide: ConfigService,
useValue: {
getFeatureFlag: () => Promise.resolve(false),
getFeatureFlag$: () => of(false),
},
},
{ provide: Router, useValue: {} },
{ provide: ActivatedRoute, useValue: {} },
{
@@ -89,8 +116,63 @@ describe("VaultItemDialogComponent", () => {
{ provide: ApiService, useValue: {} },
{ provide: EventCollectionService, useValue: {} },
{ provide: RoutedVaultFilterService, useValue: {} },
{
provide: CipherArchiveService,
useValue: {
userCanArchive$: jest.fn().mockReturnValue(of(true)),
hasArchiveFlagEnabled$: jest.fn().mockReturnValue(of(true)),
archiveWithServer: jest.fn().mockResolvedValue({}),
unarchiveWithServer: jest.fn().mockResolvedValue({}),
},
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
},
{
provide: CollectionService,
useValue: mock<CollectionService>(),
},
{
provide: FolderService,
useValue: mock<FolderService>(),
},
{
provide: TaskService,
useValue: mock<TaskService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{
provide: EnvironmentService,
useValue: {
environment$: of({
getIconsUrl: () => "https://example.com",
}),
},
},
{
provide: DomainSettingsService,
useValue: {
showFavicons$: of(true),
},
},
{
provide: BillingAccountProfileStateService,
useValue: {
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
},
},
{
provide: PlatformUtilsService,
useValue: {
getClientType: jest.fn().mockReturnValue("Web"),
},
},
{ provide: SyncService, useValue: {} },
{ provide: PlatformUtilsService, useValue: {} },
{ provide: CipherRiskService, useValue: {} },
],
}).compileComponents();
@@ -140,10 +222,84 @@ describe("VaultItemDialogComponent", () => {
expect(component.getTestTitle()).toBe("newItemHeaderCard");
});
});
describe("archive", () => {
it("calls archiveService to archive the cipher", async () => {
const archiveService = TestBed.inject(CipherArchiveService);
component.setTestCipher({ id: "111-222-333-4444" });
component.setTestParams({ mode: "view" });
fixture.detectChanges();
await component.archive();
expect(archiveService.archiveWithServer).toHaveBeenCalledWith("111-222-333-4444", "UserId");
});
});
describe("unarchive", () => {
it("calls archiveService to unarchive the cipher", async () => {
const archiveService = TestBed.inject(CipherArchiveService);
component.setTestCipher({ id: "111-222-333-4444" });
component.setTestParams({ mode: "form" });
fixture.detectChanges();
await component.unarchive();
expect(archiveService.unarchiveWithServer).toHaveBeenCalledWith("111-222-333-4444", "UserId");
});
});
describe("archive button", () => {
it("should show archive button when the user can archive the item and the item can be archived", () => {
component.setTestCipher({ canBeArchived: true });
(component as any).userCanArchive$ = of(true);
component.setTestParams({ mode: "form" });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeTruthy();
});
it("should not show archive button when the user cannot archive the item", () => {
(component as any).userCanArchive$ = of(false);
component.setTestCipher({});
component.setTestParams({ mode: "form" });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeFalsy();
});
it("should not show archive button when the item cannot be archived", () => {
component.setTestCipher({ canBeArchived: false });
component.setTestParams({ mode: "form" });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeFalsy();
});
});
describe("unarchive button", () => {
it("should show the unarchive button when the item is archived", () => {
component.setTestCipher({ isArchived: true });
component.setTestParams({ mode: "form" });
fixture.detectChanges();
const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']"));
expect(unarchiveButton).toBeTruthy();
});
it("should not show the unarchive button when the item is not archived", () => {
component.setTestCipher({ isArchived: false });
component.setTestParams({ mode: "form" });
fixture.detectChanges();
const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']"));
expect(unarchiveButton).toBeFalsy();
});
});
describe("submitButtonText$", () => {
it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => {
jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false));
component["cipherIsArchived"] = true;
component.setTestCipher({ isArchived: true });
fixture.detectChanges();
component["submitButtonText$"].subscribe((text) => {
expect(text).toBe("unArchiveAndSave");
@@ -153,7 +309,8 @@ describe("VaultItemDialogComponent", () => {
it("should return 'save' when cipher is archived and user has premium", (done) => {
jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(true));
component["cipherIsArchived"] = true;
component.setTestCipher({ isArchived: true });
fixture.detectChanges();
component["submitButtonText$"].subscribe((text) => {
expect(text).toBe("save");
@@ -163,7 +320,8 @@ describe("VaultItemDialogComponent", () => {
it("should return 'save' when cipher is not archived", (done) => {
jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false));
component["cipherIsArchived"] = false;
component.setTestCipher({ isArchived: false });
fixture.detectChanges();
component["submitButtonText$"].subscribe((text) => {
expect(text).toBe("save");

View File

@@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
@@ -231,6 +232,18 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
),
);
protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$;
protected userId$ = this.accountService.activeAccount$.pipe(getUserId);
/**
* Flag to indicate if the user can archive items.
* @protected
*/
protected userCanArchive$ = this.userId$.pipe(
switchMap((userId) => this.archiveService.userCanArchive$(userId)),
);
protected get isTrashFilter() {
return this.filter?.type === "trash";
}
@@ -243,6 +256,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
return this.isTrashFilter && !this.showRestore;
}
protected get showActionButtons() {
return this.cipher !== null && this.params.mode === "form" && this.formConfig.mode !== "clone";
}
/**
* Determines if the user may restore the item.
* A user may restore items if they have delete permissions and the item is in the trash.
@@ -253,8 +270,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
protected showRestore: boolean;
protected cipherIsArchived: boolean = false;
protected get loadingForm() {
return this.loadForm && !this.formReady;
}
@@ -267,15 +282,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
return this.showCipherView && !this.isTrashFilter && !this.showRestore;
}
protected get showDelete() {
// Don't show the delete button when cloning a cipher
if (this.params.mode == "form" && this.formConfig.mode === "clone") {
return false;
}
// Never show the delete button for new ciphers
return this.cipher != null;
}
protected get showCipherView() {
return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm);
}
@@ -283,13 +289,17 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
protected get submitButtonText$(): Observable<string> {
return this.userHasPremium$.pipe(
map((hasPremium) =>
this.cipherIsArchived && !hasPremium
this.isCipherArchived && !hasPremium
? this.i18nService.t("unArchiveAndSave")
: this.i18nService.t("save"),
),
);
}
protected get isCipherArchived() {
return this.cipher?.isArchived;
}
/**
* Flag to initialize/attach the form component.
*/
@@ -327,6 +337,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
private apiService: ApiService,
private eventCollectionService: EventCollectionService,
private routedVaultFilterService: RoutedVaultFilterService,
private archiveService: CipherArchiveService,
) {
this.updateTitle();
this.premiumUpgradeService.upgradeConfirmed$
@@ -339,7 +350,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.cipher = await this.getDecryptedCipherView(this.formConfig);
if (this.cipher) {
if (this.cipher.decryptionFailure) {
this.dialogService.open(DecryptionFailureDialogComponent, {
@@ -350,8 +360,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
return;
}
this.cipherIsArchived = this.cipher.isArchived;
this.collections = this.formConfig.collections.filter((c) =>
this.cipher.collectionIds?.includes(c.id),
);
@@ -406,15 +414,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
cipherView.collectionIds?.includes(c.id),
);
// Track cipher archive state for btn text and badge updates
this.cipherIsArchived = this.cipher.isArchived;
// If the cipher was newly created (via add/clone), switch the form to edit for subsequent edits.
if (this._originalFormMode === "add" || this._originalFormMode === "clone") {
this.formConfig.mode = "edit";
this.formConfig.initialValues = null;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const activeUserId = await firstValueFrom(this.userId$);
let cipher = await this.cipherService.get(cipherView.id, activeUserId);
// When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint (if not found in local state)
@@ -508,9 +513,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
result.action === AttachmentDialogResult.Removed ||
result.action === AttachmentDialogResult.Uploaded
) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(this.userId$);
let updatedCipherView: CipherView;
@@ -562,11 +565,72 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
await this.changeMode("view");
};
updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => {
this.cipher.archivedDate = archivedDate;
this.cipher.revisionDate = revisionDate;
// If we're in View mode, we don't need to update the form.
if (this.params.mode === "view") {
return;
}
this.cipherFormComponent.patchCipher((current) => {
current.revisionDate = revisionDate;
current.archivedDate = archivedDate;
return current;
});
};
archive = async () => {
const activeUserId = await firstValueFrom(this.userId$);
try {
const cipherResponse = await this.archiveService.archiveWithServer(
this.cipher.id as CipherId,
activeUserId,
);
this.updateCipherFromArchive(
new Date(cipherResponse.revisionDate),
cipherResponse.archivedDate ? new Date(cipherResponse.archivedDate) : null,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsWereSentToArchive"),
});
} catch {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
}
};
unarchive = async () => {
const activeUserId = await firstValueFrom(this.userId$);
try {
const cipherResponse = await this.archiveService.unarchiveWithServer(
this.cipher.id as CipherId,
activeUserId,
);
this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasUnarchived"),
});
} catch {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
};
private async getDecryptedCipherView(config: CipherFormConfig) {
if (config.originalCipher == null) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const activeUserId = await firstValueFrom(this.userId$);
return await this.cipherService.decrypt(config.originalCipher, activeUserId);
}

View File

@@ -11679,6 +11679,9 @@
"itemsWereSentToArchive": {
"message": "Items were sent to archive"
},
"itemWasUnarchived": {
"message": "Item was unarchived"
},
"itemUnarchived": {
"message": "Item was unarchived"
},