mirror of
https://github.com/bitwarden/clients.git
synced 2026-01-11 20:07:18 +00:00
[PM-24560] - Add Archive UI Element to View and Edit Item Cards (#16954)
Some checks failed
Testing / Run tests (push) Has been cancelled
Testing / Run Rust tests on macos-14 (push) Has been cancelled
Testing / Run Rust tests on ubuntu-22.04 (push) Has been cancelled
Testing / Run Rust tests on windows-2022 (push) Has been cancelled
Auto Update Branch / Update Branch (push) Has been cancelled
Chromatic / Check PR run (push) Has been cancelled
Lint / Lint (push) Has been cancelled
Lint / Run Rust lint on macos-14 (push) Has been cancelled
Lint / Run Rust lint on ubuntu-24.04 (push) Has been cancelled
Lint / Run Rust lint on windows-2022 (push) Has been cancelled
Scan / Check PR run (push) Has been cancelled
Testing / Rust Coverage (push) Has been cancelled
Chromatic / Chromatic (push) Has been cancelled
Scan / Checkmarx (push) Has been cancelled
Scan / Sonar (push) Has been cancelled
Testing / Upload to Codecov (push) Has been cancelled
Some checks failed
Testing / Run tests (push) Has been cancelled
Testing / Run Rust tests on macos-14 (push) Has been cancelled
Testing / Run Rust tests on ubuntu-22.04 (push) Has been cancelled
Testing / Run Rust tests on windows-2022 (push) Has been cancelled
Auto Update Branch / Update Branch (push) Has been cancelled
Chromatic / Check PR run (push) Has been cancelled
Lint / Lint (push) Has been cancelled
Lint / Run Rust lint on macos-14 (push) Has been cancelled
Lint / Run Rust lint on ubuntu-24.04 (push) Has been cancelled
Lint / Run Rust lint on windows-2022 (push) Has been cancelled
Scan / Check PR run (push) Has been cancelled
Testing / Rust Coverage (push) Has been cancelled
Chromatic / Chromatic (push) Has been cancelled
Scan / Checkmarx (push) Has been cancelled
Scan / Sonar (push) Has been cancelled
Testing / Upload to Codecov (push) Has been cancelled
* 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:
parent
1714660bde
commit
404d925f84
17 changed files with 824 additions and 122 deletions
|
|
@ -573,6 +573,9 @@
|
|||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemWasUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,14 +31,34 @@
|
|||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
slot="end"
|
||||
*ngIf="canDeleteCipher$ | async"
|
||||
[bitAction]="delete"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
bitIconButton="bwi-trash"
|
||||
[label]="'delete' | i18n"
|
||||
></button>
|
||||
<ng-container slot="end">
|
||||
@if (isEditMode) {
|
||||
@if ((archiveFlagEnabled$ | async) && isCipherArchived) {
|
||||
<button
|
||||
type="button"
|
||||
[bitAction]="unarchive"
|
||||
bitIconButton="bwi-unarchive"
|
||||
[label]="'unarchive' | i18n"
|
||||
></button>
|
||||
}
|
||||
@if ((userCanArchive$ | async) && canCipherBeArchived) {
|
||||
<button
|
||||
type="button"
|
||||
[bitAction]="archive"
|
||||
bitIconButton="bwi-archive"
|
||||
[label]="'archiveVerb' | i18n"
|
||||
></button>
|
||||
}
|
||||
}
|
||||
@if (canDeleteCipher$ | async) {
|
||||
<button
|
||||
[bitAction]="delete"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
bitIconButton="bwi-trash"
|
||||
[label]="'delete' | i18n"
|
||||
></button>
|
||||
}
|
||||
</ng-container>
|
||||
</popup-footer>
|
||||
</popup-page>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
|
|
@ -12,13 +13,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
|||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } 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 { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
ArchiveCipherUtilitiesService,
|
||||
CipherFormConfig,
|
||||
CipherFormConfigService,
|
||||
CipherFormMode,
|
||||
|
|
@ -45,15 +49,15 @@ describe("AddEditV2Component", () => {
|
|||
let cipherServiceMock: MockProxy<CipherService>;
|
||||
|
||||
const buildConfigResponse = { originalCipher: {} } as CipherFormConfig;
|
||||
const buildConfig = jest.fn((mode: CipherFormMode) =>
|
||||
Promise.resolve({ ...buildConfigResponse, mode }),
|
||||
);
|
||||
const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode }));
|
||||
const queryParams$ = new BehaviorSubject({});
|
||||
const disable = jest.fn();
|
||||
const navigate = jest.fn();
|
||||
const back = jest.fn().mockResolvedValue(null);
|
||||
const setHistory = jest.fn();
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
const openSimpleDialog = jest.fn().mockResolvedValue(true);
|
||||
const cipherArchiveService = mock<CipherArchiveService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
buildConfig.mockClear();
|
||||
|
|
@ -61,6 +65,10 @@ describe("AddEditV2Component", () => {
|
|||
navigate.mockClear();
|
||||
back.mockClear();
|
||||
collect.mockClear();
|
||||
openSimpleDialog.mockClear();
|
||||
|
||||
cipherArchiveService.hasArchiveFlagEnabled$ = of(true);
|
||||
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
|
||||
addEditCipherInfo$ = new BehaviorSubject<AddEditCipherInfo | null>(null);
|
||||
cipherServiceMock = mock<CipherService>({
|
||||
|
|
@ -83,10 +91,21 @@ describe("AddEditV2Component", () => {
|
|||
{
|
||||
provide: CipherAuthorizationService,
|
||||
useValue: {
|
||||
canDeleteCipher$: jest.fn().mockReturnValue(true),
|
||||
canDeleteCipher$: jest.fn().mockReturnValue(of(true)),
|
||||
},
|
||||
},
|
||||
{ provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) },
|
||||
{
|
||||
provide: CipherArchiveService,
|
||||
useValue: cipherArchiveService,
|
||||
},
|
||||
{
|
||||
provide: ArchiveCipherUtilitiesService,
|
||||
useValue: {
|
||||
archiveCipher: jest.fn().mockResolvedValue(null),
|
||||
unarchiveCipher: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideProvider(CipherFormConfigService, {
|
||||
|
|
@ -94,6 +113,11 @@ describe("AddEditV2Component", () => {
|
|||
buildConfig,
|
||||
},
|
||||
})
|
||||
.overrideProvider(DialogService, {
|
||||
useValue: {
|
||||
openSimpleDialog,
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AddEditV2Component);
|
||||
|
|
@ -356,6 +380,111 @@ describe("AddEditV2Component", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("archive", () => {
|
||||
it("calls archiveCipherUtilsService service to archive the cipher", async () => {
|
||||
buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher;
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
|
||||
await fixture.whenStable();
|
||||
await component.archive();
|
||||
|
||||
expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "222-333-444-5555" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchive", () => {
|
||||
it("calls archiveCipherUtilsService service to unarchive the cipher", async () => {
|
||||
buildConfigResponse.originalCipher = {
|
||||
id: "222-333-444-5555",
|
||||
archivedDate: new Date(),
|
||||
edit: true,
|
||||
} as Cipher;
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
|
||||
await component.unarchive();
|
||||
|
||||
expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "222-333-444-5555" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive button", () => {
|
||||
beforeEach(() => {
|
||||
// prevent form from rendering
|
||||
jest.spyOn(component as any, "loading", "get").mockReturnValue(true);
|
||||
buildConfigResponse.originalCipher = { archivedDate: undefined, edit: true } as Cipher;
|
||||
});
|
||||
|
||||
it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => {
|
||||
cipherArchiveService.userCanArchive$.mockReturnValue(of(true));
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']"));
|
||||
expect(archiveBtn).toBeTruthy();
|
||||
}));
|
||||
|
||||
it("does not show the archive button when the user cannot archive", fakeAsync(() => {
|
||||
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']"));
|
||||
expect(archiveBtn).toBeFalsy();
|
||||
}));
|
||||
|
||||
it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => {
|
||||
cipherArchiveService.userCanArchive$.mockReturnValue(of(true));
|
||||
buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher;
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']"));
|
||||
expect(archiveBtn).toBeFalsy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("unarchive button", () => {
|
||||
beforeEach(() => {
|
||||
// prevent form from rendering
|
||||
jest.spyOn(component as any, "loading", "get").mockReturnValue(true);
|
||||
buildConfigResponse.originalCipher = { edit: true } as Cipher;
|
||||
});
|
||||
|
||||
it("shows the unarchive button when the cipher is archived", fakeAsync(() => {
|
||||
buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher;
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const unarchiveBtn = fixture.debugElement.query(
|
||||
By.css("button[biticonbutton='bwi-unarchive']"),
|
||||
);
|
||||
expect(unarchiveBtn).toBeTruthy();
|
||||
}));
|
||||
|
||||
it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => {
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const unarchiveBtn = fixture.debugElement.query(
|
||||
By.css("button[biticonbutton='bwi-unarchive']"),
|
||||
);
|
||||
expect(unarchiveBtn).toBeFalsy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("dialogService openSimpleDialog called when deleteBtn is hit", async () => {
|
||||
const dialogSpy = jest
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { Component, OnInit, OnDestroy, viewChild } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
|
|
@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
|||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } 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 { CipherType, toCipherType } from "@bitwarden/common/vault/enums";
|
||||
|
|
@ -31,6 +32,8 @@ import {
|
|||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
ArchiveCipherUtilitiesService,
|
||||
CipherFormComponent,
|
||||
CipherFormConfig,
|
||||
CipherFormConfigService,
|
||||
CipherFormGenerationService,
|
||||
|
|
@ -159,6 +162,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
|||
],
|
||||
})
|
||||
export class AddEditV2Component implements OnInit, OnDestroy {
|
||||
readonly cipherFormComponent = viewChild(CipherFormComponent);
|
||||
headerText: string;
|
||||
config: CipherFormConfig;
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
|
|
@ -171,6 +175,18 @@ export class AddEditV2Component implements OnInit, OnDestroy {
|
|||
return this.config?.originalCipher?.id as CipherId;
|
||||
}
|
||||
|
||||
get cipher(): CipherView {
|
||||
return new CipherView(this.config?.originalCipher);
|
||||
}
|
||||
|
||||
get canCipherBeArchived(): boolean {
|
||||
return this.cipher?.canBeArchived;
|
||||
}
|
||||
|
||||
get isCipherArchived(): boolean {
|
||||
return this.cipher?.isArchived;
|
||||
}
|
||||
|
||||
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
|
||||
private fido2PopoutSessionData: Fido2SessionData;
|
||||
|
||||
|
|
@ -182,6 +198,16 @@ export class AddEditV2Component implements OnInit, OnDestroy {
|
|||
return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem);
|
||||
}
|
||||
|
||||
protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$;
|
||||
|
||||
/**
|
||||
* Flag to indicate if the user can archive items.
|
||||
* @protected
|
||||
*/
|
||||
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => this.archiveService.userCanArchive$(account.id)),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
|
|
@ -196,6 +222,8 @@ export class AddEditV2Component implements OnInit, OnDestroy {
|
|||
private dialogService: DialogService,
|
||||
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||
private accountService: AccountService,
|
||||
private archiveService: CipherArchiveService,
|
||||
private archiveCipherUtilsService: ArchiveCipherUtilitiesService,
|
||||
) {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
|
|
@ -322,6 +350,10 @@ export class AddEditV2Component implements OnInit, OnDestroy {
|
|||
await BrowserApi.sendMessage("addEditCipherSubmitted");
|
||||
}
|
||||
|
||||
get isEditMode(): boolean {
|
||||
return ["edit", "partial-edit"].includes(this.config?.mode);
|
||||
}
|
||||
|
||||
subscribeToParams(): void {
|
||||
this.route.queryParams
|
||||
.pipe(
|
||||
|
|
@ -430,6 +462,40 @@ export class AddEditV2Component implements OnInit, OnDestroy {
|
|||
return this.i18nService.t(translation[type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cipher in the form after archiving/unarchiving.
|
||||
* @param revisionDate The new revision date.
|
||||
* @param archivedDate The new archived date (null if unarchived).
|
||||
**/
|
||||
updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => {
|
||||
this.cipherFormComponent().patchCipher((current) => {
|
||||
current.revisionDate = revisionDate;
|
||||
current.archivedDate = archivedDate;
|
||||
return current;
|
||||
});
|
||||
};
|
||||
|
||||
archive = async () => {
|
||||
const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true);
|
||||
|
||||
if (!cipherResponse) {
|
||||
return;
|
||||
}
|
||||
this.updateCipherFromArchive(
|
||||
new Date(cipherResponse.revisionDate),
|
||||
new Date(cipherResponse.archivedDate),
|
||||
);
|
||||
};
|
||||
|
||||
unarchive = async () => {
|
||||
const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher);
|
||||
|
||||
if (!cipherResponse) {
|
||||
return;
|
||||
}
|
||||
this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null);
|
||||
};
|
||||
|
||||
delete = async () => {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteItem" },
|
||||
|
|
|
|||
|
|
@ -3,37 +3,47 @@
|
|||
<app-pop-out slot="end" />
|
||||
</popup-header>
|
||||
|
||||
<app-cipher-view *ngIf="cipher" [cipher]="cipher"></app-cipher-view>
|
||||
@if (cipher) {
|
||||
<app-cipher-view [cipher]="cipher"></app-cipher-view>
|
||||
}
|
||||
|
||||
<popup-footer slot="footer" *ngIf="showFooter$ | async">
|
||||
<button
|
||||
*ngIf="!cipher.isDeleted"
|
||||
buttonType="primary"
|
||||
type="button"
|
||||
bitButton
|
||||
(click)="editCipher()"
|
||||
>
|
||||
{{ "edit" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="cipher.isDeleted && cipher.permissions.restore"
|
||||
buttonType="primary"
|
||||
type="button"
|
||||
bitButton
|
||||
[bitAction]="restore"
|
||||
>
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
slot="end"
|
||||
*ngIf="canDeleteCipher$ | async"
|
||||
[bitAction]="delete"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
bitIconButton="bwi-trash"
|
||||
[label]="(cipher.isDeleted ? 'deleteForever' : 'delete') | i18n"
|
||||
></button>
|
||||
@if (!cipher.isDeleted) {
|
||||
<button buttonType="primary" type="button" bitButton (click)="editCipher()">
|
||||
{{ "edit" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (cipher.isDeleted && cipher.permissions.restore) {
|
||||
<button buttonType="primary" type="button" bitButton [bitAction]="restore">
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<ng-container slot="end">
|
||||
@if ((archiveFlagEnabled$ | async) && cipher.isArchived) {
|
||||
<button
|
||||
type="button"
|
||||
[bitAction]="unarchive"
|
||||
bitIconButton="bwi-unarchive"
|
||||
[label]="'unarchive' | i18n"
|
||||
></button>
|
||||
}
|
||||
@if ((userCanArchive$ | async) && cipher.canBeArchived) {
|
||||
<button
|
||||
type="button"
|
||||
[bitAction]="archive"
|
||||
bitIconButton="bwi-archive"
|
||||
[label]="'archiveVerb' | i18n"
|
||||
></button>
|
||||
}
|
||||
@if (canDeleteCipher$ | async) {
|
||||
<button
|
||||
[bitAction]="delete"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
bitIconButton="bwi-trash"
|
||||
[label]="(cipher.isDeleted ? 'deleteForever' : 'delete') | i18n"
|
||||
></button>
|
||||
}
|
||||
</ng-container>
|
||||
</popup-footer>
|
||||
</popup-page>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing";
|
||||
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, Subject } 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 {
|
||||
AUTOFILL_ID,
|
||||
|
|
@ -11,20 +15,32 @@ import {
|
|||
COPY_USERNAME_ID,
|
||||
COPY_VERIFICATION_CODE_ID,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
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 { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
ArchiveCipherUtilitiesService,
|
||||
CopyCipherFieldService,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils";
|
||||
|
|
@ -62,7 +78,9 @@ describe("ViewV2Component", () => {
|
|||
username: "test-username",
|
||||
password: "test-password",
|
||||
totp: "123",
|
||||
uris: [],
|
||||
},
|
||||
card: {},
|
||||
} as unknown as CipherView;
|
||||
|
||||
const mockPasswordRepromptService = {
|
||||
|
|
@ -84,6 +102,8 @@ describe("ViewV2Component", () => {
|
|||
softDeleteWithServer: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const cipherArchiveService = mock<CipherArchiveService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
mockCipherService.cipherViews$.mockClear();
|
||||
mockCipherService.deleteWithServer.mockClear();
|
||||
|
|
@ -97,6 +117,10 @@ describe("ViewV2Component", () => {
|
|||
back.mockClear();
|
||||
showToast.mockClear();
|
||||
showPasswordPrompt.mockClear();
|
||||
cipherArchiveService.hasArchiveFlagEnabled$ = of(true);
|
||||
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData);
|
||||
cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ViewV2Component],
|
||||
|
|
@ -142,6 +166,61 @@ describe("ViewV2Component", () => {
|
|||
provide: PasswordRepromptService,
|
||||
useValue: mockPasswordRepromptService,
|
||||
},
|
||||
{
|
||||
provide: CipherArchiveService,
|
||||
useValue: cipherArchiveService,
|
||||
},
|
||||
{
|
||||
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: ArchiveCipherUtilitiesService,
|
||||
useValue: {
|
||||
archiveCipher: jest.fn().mockResolvedValue(null),
|
||||
unarchiveCipher: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CipherRiskService,
|
||||
useValue: mock<CipherRiskService>(),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideProvider(DialogService, {
|
||||
|
|
@ -154,6 +233,7 @@ describe("ViewV2Component", () => {
|
|||
fixture = TestBed.createComponent(ViewV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
(component as any).showFooter$ = of(true);
|
||||
});
|
||||
|
||||
describe("queryParams", () => {
|
||||
|
|
@ -352,6 +432,93 @@ describe("ViewV2Component", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
describe("archive button", () => {
|
||||
it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => {
|
||||
jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true));
|
||||
component.cipher = { ...mockCipher, canBeArchived: true } as CipherView;
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']"));
|
||||
expect(archiveBtn).toBeTruthy();
|
||||
}));
|
||||
|
||||
it("does not show the archive button when the user cannot archive", fakeAsync(() => {
|
||||
jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false));
|
||||
component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView;
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']"));
|
||||
expect(archiveBtn).toBeFalsy();
|
||||
}));
|
||||
|
||||
it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => {
|
||||
jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true));
|
||||
component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView;
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']"));
|
||||
expect(archiveBtn).toBeFalsy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("unarchive button", () => {
|
||||
it("shows the unarchive button when the cipher is archived", fakeAsync(() => {
|
||||
component.cipher = { ...mockCipher, isArchived: true } as CipherView;
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const unarchiveBtn = fixture.debugElement.query(
|
||||
By.css("button[biticonbutton='bwi-unarchive']"),
|
||||
);
|
||||
expect(unarchiveBtn).toBeTruthy();
|
||||
}));
|
||||
|
||||
it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => {
|
||||
component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView;
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const unarchiveBtn = fixture.debugElement.query(
|
||||
By.css("button[biticonbutton='bwi-unarchive']"),
|
||||
);
|
||||
expect(unarchiveBtn).toBeFalsy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("archive", () => {
|
||||
beforeEach(() => {
|
||||
component.cipher = { ...mockCipher, canBeArchived: true } as CipherView;
|
||||
});
|
||||
|
||||
it("calls archive service to archive the cipher", async () => {
|
||||
await component.archive();
|
||||
|
||||
expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "122-333-444" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchive", () => {
|
||||
it("calls archive service to unarchive the cipher", async () => {
|
||||
component.cipher = { ...mockCipher, isArchived: true } as CipherView;
|
||||
|
||||
await component.unarchive();
|
||||
|
||||
expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "122-333-444" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
beforeEach(() => {
|
||||
component.cipher = mockCipher;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums";
|
|||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } 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";
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
ArchiveCipherUtilitiesService,
|
||||
ChangeLoginPasswordService,
|
||||
CipherViewComponent,
|
||||
CopyCipherFieldService,
|
||||
|
|
@ -114,6 +116,10 @@ export class ViewV2Component {
|
|||
senderTabId?: number;
|
||||
|
||||
protected showFooter$: Observable<boolean>;
|
||||
protected userCanArchive$ = this.accountService.activeAccount$
|
||||
.pipe(getUserId)
|
||||
.pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId)));
|
||||
protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$;
|
||||
|
||||
constructor(
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
|
|
@ -131,6 +137,8 @@ export class ViewV2Component {
|
|||
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||
private copyCipherFieldService: CopyCipherFieldService,
|
||||
private popupScrollPositionService: VaultPopupScrollPositionService,
|
||||
private archiveService: CipherArchiveService,
|
||||
private archiveCipherUtilsService: ArchiveCipherUtilitiesService,
|
||||
) {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
|
|
@ -272,6 +280,24 @@ export class ViewV2Component {
|
|||
});
|
||||
};
|
||||
|
||||
archive = async () => {
|
||||
const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true);
|
||||
|
||||
if (!cipherResponse) {
|
||||
return;
|
||||
}
|
||||
this.cipher.archivedDate = new Date(cipherResponse.archivedDate);
|
||||
};
|
||||
|
||||
unarchive = async () => {
|
||||
const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher);
|
||||
|
||||
if (!cipherResponse) {
|
||||
return;
|
||||
}
|
||||
this.cipher.archivedDate = null;
|
||||
};
|
||||
|
||||
protected deleteCipher() {
|
||||
return this.cipher.isDeleted
|
||||
? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue