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:
committed by
jaasen-livefront
parent
246a549f58
commit
348ed4b616
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -11679,6 +11679,9 @@
|
||||
"itemsWereSentToArchive": {
|
||||
"message": "Items were sent to archive"
|
||||
},
|
||||
"itemWasUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user