mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +00:00
[PM-19337] Desktop Archive (#16787)
* fix typescript errors * add archive filter to desktop * exclude archive items from search * add left click menu options for archive * add MP prompt checks for archive/unarchive * assure that a cipher cannot be assigned to collections when archived * move cipher from archive vault if a user loses premium * ensure clone only shows when archive is active * refactor right side footer actions to getter so it can be expanded * add confirmation prompt for archiving cipher * add utility service for archiving/unarchiving a cipher * add archive/unarchive ability to footer of desktop * add tests for utilities service * handle null emission of `cipherViews$` * use active user id directly from activeAccount * remove unneeded load of vault items * refresh internal cipher when archive is toggled - forcing the footer view to update * refresh current cipher when archived from the left-click menu * only show archive for viewing a cipher * add cipher form tests * clear archive date when soft deleting * update success messages * remove archive date when cloning * fix crowdin message swap * fix test * move MP prompt before archive prompt - match PM-26994 * fix failing test * add optional chaining * move template logic into class * condense logic * `unArchive`
This commit is contained in:
@@ -4167,7 +4167,7 @@
|
|||||||
"itemWasSentToArchive": {
|
"itemWasSentToArchive": {
|
||||||
"message": "Item was sent to archive"
|
"message": "Item was sent to archive"
|
||||||
},
|
},
|
||||||
"itemUnarchived": {
|
"itemWasUnarchived": {
|
||||||
"message": "Item was unarchived"
|
"message": "Item was unarchived"
|
||||||
},
|
},
|
||||||
"archiveItem": {
|
"archiveItem": {
|
||||||
|
|||||||
@@ -46,7 +46,23 @@
|
|||||||
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
|
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div class="right" *ngIf="cipher.permissions.delete && (action === 'edit' || action === 'view')">
|
<div class="right" *ngIf="hasFooterAction">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
*ngIf="showArchiveButton"
|
||||||
|
(click)="archive()"
|
||||||
|
appA11yTitle="{{ 'archiveVerb' | i18n }}"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-archive bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
*ngIf="showUnarchiveButton"
|
||||||
|
(click)="unarchive()"
|
||||||
|
appA11yTitle="{{ 'unarchive' | i18n }}"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-unarchive bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="delete()"
|
(click)="delete()"
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Input, Output, EventEmitter, Component, OnInit, ViewChild } from "@angular/core";
|
import {
|
||||||
import { firstValueFrom } from "rxjs";
|
Input,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
OnChanges,
|
||||||
|
SimpleChanges,
|
||||||
|
} from "@angular/core";
|
||||||
|
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
@@ -8,19 +17,20 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
|||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||||
import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components";
|
import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-vault-item-footer",
|
selector: "app-vault-item-footer",
|
||||||
templateUrl: "item-footer.component.html",
|
templateUrl: "item-footer.component.html",
|
||||||
imports: [ButtonModule, CommonModule, JslibModule],
|
imports: [ButtonModule, CommonModule, JslibModule],
|
||||||
})
|
})
|
||||||
export class ItemFooterComponent implements OnInit {
|
export class ItemFooterComponent implements OnInit, OnChanges {
|
||||||
@Input({ required: true }) cipher: CipherView = new CipherView();
|
@Input({ required: true }) cipher: CipherView = new CipherView();
|
||||||
@Input() collectionId: string | null = null;
|
@Input() collectionId: string | null = null;
|
||||||
@Input({ required: true }) action: string = "view";
|
@Input({ required: true }) action: string = "view";
|
||||||
@@ -30,11 +40,15 @@ export class ItemFooterComponent implements OnInit {
|
|||||||
@Output() onDelete = new EventEmitter<CipherView>();
|
@Output() onDelete = new EventEmitter<CipherView>();
|
||||||
@Output() onRestore = new EventEmitter<CipherView>();
|
@Output() onRestore = new EventEmitter<CipherView>();
|
||||||
@Output() onCancel = new EventEmitter<CipherView>();
|
@Output() onCancel = new EventEmitter<CipherView>();
|
||||||
|
@Output() onArchiveToggle = new EventEmitter<CipherView>();
|
||||||
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
|
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
|
||||||
|
|
||||||
activeUserId: UserId | null = null;
|
activeUserId: UserId | null = null;
|
||||||
passwordReprompted: boolean = false;
|
passwordReprompted: boolean = false;
|
||||||
|
|
||||||
|
protected showArchiveButton = false;
|
||||||
|
protected showUnarchiveButton = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected dialogService: DialogService,
|
protected dialogService: DialogService,
|
||||||
@@ -44,11 +58,20 @@ export class ItemFooterComponent implements OnInit {
|
|||||||
protected toastService: ToastService,
|
protected toastService: ToastService,
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected logService: LogService,
|
protected logService: LogService,
|
||||||
|
protected cipherArchiveService: CipherArchiveService,
|
||||||
|
protected archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
this.passwordReprompted = this.masterPasswordAlreadyPrompted;
|
this.passwordReprompted = this.masterPasswordAlreadyPrompted;
|
||||||
|
await this.checkArchiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnChanges(changes: SimpleChanges) {
|
||||||
|
if (changes.cipher) {
|
||||||
|
await this.checkArchiveState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clone() {
|
async clone() {
|
||||||
@@ -76,6 +99,14 @@ export class ItemFooterComponent implements OnInit {
|
|||||||
this.onEdit.emit(this.cipher);
|
this.onEdit.emit(this.cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get hasFooterAction() {
|
||||||
|
return (
|
||||||
|
this.showArchiveButton ||
|
||||||
|
this.showUnarchiveButton ||
|
||||||
|
(this.cipher.permissions?.delete && (this.action === "edit" || this.action === "view"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.onCancel.emit(this.cipher);
|
this.onCancel.emit(this.cipher);
|
||||||
}
|
}
|
||||||
@@ -151,4 +182,36 @@ export class ItemFooterComponent implements OnInit {
|
|||||||
|
|
||||||
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
|
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async archive() {
|
||||||
|
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher);
|
||||||
|
this.onArchiveToggle.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async unarchive() {
|
||||||
|
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher);
|
||||||
|
this.onArchiveToggle.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkArchiveState() {
|
||||||
|
const cipherCanBeArchived = !this.cipher.isDeleted && this.cipher.organizationId == null;
|
||||||
|
const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(
|
||||||
|
getUserId,
|
||||||
|
switchMap((id) =>
|
||||||
|
combineLatest([
|
||||||
|
this.cipherArchiveService.userCanArchive$(id),
|
||||||
|
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.showArchiveButton =
|
||||||
|
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;
|
||||||
|
|
||||||
|
// A user should always be able to unarchive an archived item
|
||||||
|
this.showUnarchiveButton =
|
||||||
|
hasArchiveFlagEnabled && this.action === "view" && this.cipher.isArchived;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
class="filter-option"
|
||||||
|
*ngIf="!hideArchive"
|
||||||
|
[ngClass]="{ active: activeFilter.status === 'archive' }"
|
||||||
|
>
|
||||||
|
<span class="filter-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-button"
|
||||||
|
(click)="applyFilter('archive')"
|
||||||
|
[attr.aria-pressed]="activeFilter.status === 'archive'"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i> {{ "archiveNoun" | i18n }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
class="filter-option"
|
class="filter-option"
|
||||||
*ngIf="!hideTrash"
|
*ngIf="!hideTrash"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
class="filter"
|
class="filter"
|
||||||
[hideFavorites]="hideFavorites"
|
[hideFavorites]="hideFavorites"
|
||||||
[hideTrash]="hideTrash"
|
[hideTrash]="hideTrash"
|
||||||
|
[hideArchive]="!showArchiveVaultFilter"
|
||||||
[activeFilter]="activeFilter"
|
[activeFilter]="activeFilter"
|
||||||
(onFilterChange)="applyFilter($event)"
|
(onFilterChange)="applyFilter($event)"
|
||||||
></app-status-filter>
|
></app-status-filter>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { distinctUntilChanged, debounceTime } from "rxjs";
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
|
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||||
@@ -33,8 +34,9 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
|
|||||||
cipherService: CipherService,
|
cipherService: CipherService,
|
||||||
accountService: AccountService,
|
accountService: AccountService,
|
||||||
restrictedItemTypesService: RestrictedItemTypesService,
|
restrictedItemTypesService: RestrictedItemTypesService,
|
||||||
|
configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
super(searchService, cipherService, accountService, restrictedItemTypesService);
|
super(searchService, cipherService, accountService, restrictedItemTypesService, configService);
|
||||||
|
|
||||||
this.searchBarService.searchText$
|
this.searchBarService.searchText$
|
||||||
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
|
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
(onClone)="cloneCipher($event)"
|
(onClone)="cloneCipher($event)"
|
||||||
(onDelete)="deleteCipher()"
|
(onDelete)="deleteCipher()"
|
||||||
(onCancel)="cancelCipher($event)"
|
(onCancel)="cancelCipher($event)"
|
||||||
|
(onArchiveToggle)="refreshCurrentCipher()"
|
||||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||||
></app-vault-item-footer>
|
></app-vault-item-footer>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
@@ -33,6 +35,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
|||||||
import { getByIds } from "@bitwarden/common/platform/misc";
|
import { getByIds } from "@bitwarden/common/platform/misc";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { CipherId, 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||||
@@ -74,6 +77,7 @@ import {
|
|||||||
DefaultCipherFormConfigService,
|
DefaultCipherFormConfigService,
|
||||||
PasswordRepromptService,
|
PasswordRepromptService,
|
||||||
CipherFormComponent,
|
CipherFormComponent,
|
||||||
|
ArchiveCipherUtilitiesService,
|
||||||
} from "@bitwarden/vault";
|
} from "@bitwarden/vault";
|
||||||
|
|
||||||
import { NavComponent } from "../../../app/layout/nav.component";
|
import { NavComponent } from "../../../app/layout/nav.component";
|
||||||
@@ -211,6 +215,9 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
private folderService: FolderService,
|
private folderService: FolderService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private authRequestService: AuthRequestServiceAbstraction,
|
private authRequestService: AuthRequestServiceAbstraction,
|
||||||
|
private cipherArchiveService: CipherArchiveService,
|
||||||
|
private policyService: PolicyService,
|
||||||
|
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@@ -490,6 +497,12 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
|
|
||||||
async viewCipherMenu(c: CipherViewLike) {
|
async viewCipherMenu(c: CipherViewLike) {
|
||||||
const cipher = await this.cipherService.getFullCipherView(c);
|
const cipher = await this.cipherService.getFullCipherView(c);
|
||||||
|
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
|
const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId));
|
||||||
|
const orgOwnershipPolicy = await firstValueFrom(
|
||||||
|
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||||
|
);
|
||||||
|
|
||||||
const menu: RendererMenuItem[] = [
|
const menu: RendererMenuItem[] = [
|
||||||
{
|
{
|
||||||
label: this.i18nService.t("view"),
|
label: this.i18nService.t("view"),
|
||||||
@@ -514,7 +527,11 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!cipher.organizationId) {
|
|
||||||
|
const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy;
|
||||||
|
const canCloneArchived = !cipher.isArchived || userCanArchive;
|
||||||
|
|
||||||
|
if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) {
|
||||||
menu.push({
|
menu.push({
|
||||||
label: this.i18nService.t("clone"),
|
label: this.i18nService.t("clone"),
|
||||||
click: () => {
|
click: () => {
|
||||||
@@ -538,6 +555,26 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userCanArchive && !cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) {
|
||||||
|
menu.push({
|
||||||
|
label: this.i18nService.t("archiveVerb"),
|
||||||
|
click: async () => {
|
||||||
|
await this.archiveCipherUtilitiesService.archiveCipher(cipher);
|
||||||
|
await this.refreshCurrentCipher();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cipher.isArchived) {
|
||||||
|
menu.push({
|
||||||
|
label: this.i18nService.t("unArchive"),
|
||||||
|
click: async () => {
|
||||||
|
await this.archiveCipherUtilitiesService.unarchiveCipher(cipher);
|
||||||
|
await this.refreshCurrentCipher();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switch (cipher.type) {
|
switch (cipher.type) {
|
||||||
case CipherType.Login:
|
case CipherType.Login:
|
||||||
if (
|
if (
|
||||||
@@ -723,8 +760,6 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
|
|
||||||
this.cipherId = cipher.id;
|
this.cipherId = cipher.id;
|
||||||
this.cipher = cipher;
|
this.cipher = cipher;
|
||||||
|
|
||||||
await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {});
|
|
||||||
await this.go().catch(() => {});
|
await this.go().catch(() => {});
|
||||||
await this.vaultItemsComponent?.refresh().catch(() => {});
|
await this.vaultItemsComponent?.refresh().catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -757,7 +792,11 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
);
|
);
|
||||||
this.activeFilter = vaultFilter;
|
this.activeFilter = vaultFilter;
|
||||||
await this.vaultItemsComponent
|
await this.vaultItemsComponent
|
||||||
?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash")
|
?.reload(
|
||||||
|
this.activeFilter.buildFilter(),
|
||||||
|
vaultFilter.status === "trash",
|
||||||
|
vaultFilter.status === "archive",
|
||||||
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
await this.go().catch(() => {});
|
await this.go().catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -831,6 +870,20 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh the current cipher object */
|
||||||
|
protected async refreshCurrentCipher() {
|
||||||
|
if (!this.cipher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cipher = await firstValueFrom(
|
||||||
|
this.cipherService.cipherViews$(this.activeUserId!).pipe(
|
||||||
|
filter((c) => !!c),
|
||||||
|
map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private dirtyInput(): boolean {
|
private dirtyInput(): boolean {
|
||||||
return (
|
return (
|
||||||
(this.action === "add" || this.action === "edit" || this.action === "clone") &&
|
(this.action === "add" || this.action === "edit" || this.action === "clone") &&
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import {
|
|||||||
of,
|
of,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
take,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
@@ -37,6 +40,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
|||||||
loaded = false;
|
loaded = false;
|
||||||
ciphers: C[] = [];
|
ciphers: C[] = [];
|
||||||
deleted = false;
|
deleted = false;
|
||||||
|
archived = false;
|
||||||
organization: Organization;
|
organization: Organization;
|
||||||
CipherType = CipherType;
|
CipherType = CipherType;
|
||||||
|
|
||||||
@@ -73,13 +77,24 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
|||||||
this._filter$.next(value);
|
this._filter$.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private archiveFeatureEnabled = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected searchService: SearchService,
|
protected searchService: SearchService,
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected accountService: AccountService,
|
protected accountService: AccountService,
|
||||||
protected restrictedItemTypesService: RestrictedItemTypesService,
|
protected restrictedItemTypesService: RestrictedItemTypesService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.subscribeToCiphers();
|
this.subscribeToCiphers();
|
||||||
|
|
||||||
|
// Check if archive feature flag is enabled
|
||||||
|
this.configService
|
||||||
|
.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive)
|
||||||
|
.pipe(takeUntilDestroyed(), take(1))
|
||||||
|
.subscribe((isEnabled) => {
|
||||||
|
this.archiveFeatureEnabled = isEnabled;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -87,19 +102,20 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
|||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(filter: (cipher: C) => boolean = null, deleted = false) {
|
async load(filter: (cipher: C) => boolean = null, deleted = false, archived = false) {
|
||||||
this.deleted = deleted ?? false;
|
this.deleted = deleted ?? false;
|
||||||
|
this.archived = archived;
|
||||||
await this.applyFilter(filter);
|
await this.applyFilter(filter);
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reload(filter: (cipher: C) => boolean = null, deleted = false) {
|
async reload(filter: (cipher: C) => boolean = null, deleted = false, archived = false) {
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
await this.load(filter, deleted);
|
await this.load(filter, deleted, archived);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
await this.reload(this.filter, this.deleted);
|
await this.reload(this.filter, this.deleted, this.archived);
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyFilter(filter: (cipher: C) => boolean = null) {
|
async applyFilter(filter: (cipher: C) => boolean = null) {
|
||||||
@@ -125,6 +141,16 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
|||||||
protected deletedFilter: (cipher: C) => boolean = (c) =>
|
protected deletedFilter: (cipher: C) => boolean = (c) =>
|
||||||
CipherViewLikeUtils.isDeleted(c) === this.deleted;
|
CipherViewLikeUtils.isDeleted(c) === this.deleted;
|
||||||
|
|
||||||
|
protected archivedFilter: (cipher: C) => boolean = (c) => {
|
||||||
|
// When the archive feature is not enabled,
|
||||||
|
// always return true to avoid filtering out any items.
|
||||||
|
if (!this.archiveFeatureEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CipherViewLikeUtils.isArchived(c) === this.archived;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates stream of dependencies that results in the list of ciphers to display
|
* Creates stream of dependencies that results in the list of ciphers to display
|
||||||
* within the vault list.
|
* within the vault list.
|
||||||
@@ -158,7 +184,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
|||||||
return this.searchService.searchCiphers(
|
return this.searchService.searchCiphers(
|
||||||
userId,
|
userId,
|
||||||
searchText,
|
searchText,
|
||||||
[filter, this.deletedFilter, restrictedTypeFilter],
|
[filter, this.deletedFilter, this.archivedFilter, restrictedTypeFilter],
|
||||||
allCiphers,
|
allCiphers,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { inject, Injectable } from "@angular/core";
|
import { inject, Injectable } from "@angular/core";
|
||||||
import { combineLatest, Observable, switchMap } from "rxjs";
|
import { combineLatest, filter, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
@@ -20,7 +20,7 @@ export class NewItemNudgeService extends DefaultSingleNudgeService {
|
|||||||
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
this.getNudgeStatus$(nudgeType, userId),
|
this.getNudgeStatus$(nudgeType, userId),
|
||||||
this.cipherService.cipherViews$(userId),
|
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(async ([nudgeStatus, ciphers]) => {
|
switchMap(async ([nudgeStatus, ciphers]) => {
|
||||||
if (nudgeStatus.hasSpotlightDismissed) {
|
if (nudgeStatus.hasSpotlightDismissed) {
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { VaultFilter } from "../models/vault-filter.model";
|
|||||||
export class StatusFilterComponent {
|
export class StatusFilterComponent {
|
||||||
@Input() hideFavorites = false;
|
@Input() hideFavorites = false;
|
||||||
@Input() hideTrash = false;
|
@Input() hideTrash = false;
|
||||||
|
@Input() hideArchive = false;
|
||||||
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
|
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
|
||||||
@Input() activeFilter: VaultFilter;
|
@Input() activeFilter: VaultFilter;
|
||||||
|
|
||||||
get show() {
|
get show() {
|
||||||
return !(this.hideFavorites && this.hideTrash);
|
return !(this.hideFavorites && this.hideTrash && this.hideArchive);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFilter(cipherStatus: CipherStatus) {
|
applyFilter(cipherStatus: CipherStatus) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
|||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||||
|
|
||||||
@@ -42,9 +43,12 @@ export class VaultFilterComponent implements OnInit {
|
|||||||
collections: DynamicTreeNode<CollectionView>;
|
collections: DynamicTreeNode<CollectionView>;
|
||||||
folders$: Observable<DynamicTreeNode<FolderView>>;
|
folders$: Observable<DynamicTreeNode<FolderView>>;
|
||||||
|
|
||||||
|
protected showArchiveVaultFilter = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected vaultFilterService: DeprecatedVaultFilterService,
|
protected vaultFilterService: DeprecatedVaultFilterService,
|
||||||
protected accountService: AccountService,
|
protected accountService: AccountService,
|
||||||
|
protected cipherArchiveService: CipherArchiveService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get displayCollections() {
|
get displayCollections() {
|
||||||
@@ -65,6 +69,15 @@ export class VaultFilterComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.folders$ = await this.vaultFilterService.buildNestedFolders();
|
this.folders$ = await this.vaultFilterService.buildNestedFolders();
|
||||||
this.collections = await this.initCollections();
|
this.collections = await this.initCollections();
|
||||||
|
|
||||||
|
const userCanArchive = await firstValueFrom(
|
||||||
|
this.cipherArchiveService.userCanArchive$(this.activeUserId),
|
||||||
|
);
|
||||||
|
const showArchiveVault = await firstValueFrom(
|
||||||
|
this.cipherArchiveService.showArchiveVault$(this.activeUserId),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.showArchiveVaultFilter = userCanArchive || showArchiveVault;
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type CipherStatus = "all" | "favorites" | "trash";
|
export type CipherStatus = "all" | "favorites" | "trash" | "archive";
|
||||||
|
|||||||
@@ -56,6 +56,34 @@ describe("VaultFilter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("given a archived cipher", () => {
|
||||||
|
const cipher = createCipher({ archivedDate: new Date() });
|
||||||
|
|
||||||
|
it("should return true when filtering for archive", () => {
|
||||||
|
const filterFunction = createFilterFunction({ status: "archive" });
|
||||||
|
|
||||||
|
const result = filterFunction(cipher);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when filtering for favorites", () => {
|
||||||
|
const filterFunction = createFilterFunction({ status: "favorites" });
|
||||||
|
|
||||||
|
const result = filterFunction(cipher);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when filtering for trash", () => {
|
||||||
|
const filterFunction = createFilterFunction({ status: "trash" });
|
||||||
|
|
||||||
|
const result = filterFunction(cipher);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("given a cipher with type", () => {
|
describe("given a cipher with type", () => {
|
||||||
it("should return true when filter matches cipher type", () => {
|
it("should return true when filter matches cipher type", () => {
|
||||||
const cipher = createCipher({ type: CipherType.Identity });
|
const cipher = createCipher({ type: CipherType.Identity });
|
||||||
@@ -103,12 +131,12 @@ describe("VaultFilter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("given a cipher without folder", () => {
|
describe("given a cipher without folder", () => {
|
||||||
const cipher = createCipher({ folderId: null });
|
const cipher = createCipher({ folderId: undefined });
|
||||||
|
|
||||||
it("should return true when filtering on unassigned folder", () => {
|
it("should return true when filtering on unassigned folder", () => {
|
||||||
const filterFunction = createFilterFunction({
|
const filterFunction = createFilterFunction({
|
||||||
selectedFolder: true,
|
selectedFolder: true,
|
||||||
selectedFolderId: null,
|
selectedFolderId: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = filterFunction(cipher);
|
const result = filterFunction(cipher);
|
||||||
@@ -175,7 +203,7 @@ describe("VaultFilter", () => {
|
|||||||
it("should return true when filtering for unassigned collection", () => {
|
it("should return true when filtering for unassigned collection", () => {
|
||||||
const filterFunction = createFilterFunction({
|
const filterFunction = createFilterFunction({
|
||||||
selectedCollection: true,
|
selectedCollection: true,
|
||||||
selectedCollectionId: null,
|
selectedCollectionId: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = filterFunction(cipher);
|
const result = filterFunction(cipher);
|
||||||
@@ -195,12 +223,12 @@ describe("VaultFilter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("given an individual cipher (without organization or collection)", () => {
|
describe("given an individual cipher (without organization or collection)", () => {
|
||||||
const cipher = createCipher({ organizationId: null, collectionIds: [] });
|
const cipher = createCipher({ organizationId: undefined, collectionIds: [] });
|
||||||
|
|
||||||
it("should return false when filtering for unassigned collection", () => {
|
it("should return false when filtering for unassigned collection", () => {
|
||||||
const filterFunction = createFilterFunction({
|
const filterFunction = createFilterFunction({
|
||||||
selectedCollection: true,
|
selectedCollection: true,
|
||||||
selectedCollectionId: null,
|
selectedCollectionId: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = filterFunction(cipher);
|
const result = filterFunction(cipher);
|
||||||
@@ -209,7 +237,7 @@ describe("VaultFilter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return true when filtering for my vault only", () => {
|
it("should return true when filtering for my vault only", () => {
|
||||||
const cipher = createCipher({ organizationId: null });
|
const cipher = createCipher({ organizationId: undefined });
|
||||||
const filterFunction = createFilterFunction({
|
const filterFunction = createFilterFunction({
|
||||||
myVaultOnly: true,
|
myVaultOnly: true,
|
||||||
});
|
});
|
||||||
@@ -230,11 +258,12 @@ function createCipher(options: Partial<CipherView> = {}) {
|
|||||||
const cipher = new CipherView();
|
const cipher = new CipherView();
|
||||||
|
|
||||||
cipher.favorite = options.favorite ?? false;
|
cipher.favorite = options.favorite ?? false;
|
||||||
cipher.deletedDate = options.deletedDate;
|
cipher.deletedDate = options.deletedDate ?? null;
|
||||||
cipher.type = options.type;
|
cipher.archivedDate = options.archivedDate ?? null;
|
||||||
cipher.folderId = options.folderId;
|
cipher.type = options.type ?? CipherType.Login;
|
||||||
cipher.collectionIds = options.collectionIds;
|
cipher.folderId = options.folderId ?? undefined;
|
||||||
cipher.organizationId = options.organizationId;
|
cipher.collectionIds = options.collectionIds ?? [];
|
||||||
|
cipher.organizationId = options.organizationId ?? undefined;
|
||||||
|
|
||||||
return cipher;
|
return cipher;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class VaultFilter {
|
|||||||
if (this.status === "trash" && cipherPassesFilter) {
|
if (this.status === "trash" && cipherPassesFilter) {
|
||||||
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
|
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
|
||||||
}
|
}
|
||||||
|
if (this.status === "archive" && cipherPassesFilter) {
|
||||||
|
cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher);
|
||||||
|
}
|
||||||
if (this.cipherType != null && cipherPassesFilter) {
|
if (this.cipherType != null && cipherPassesFilter) {
|
||||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export class CipherView implements View, InitializerMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get canAssignToCollections(): boolean {
|
get canAssignToCollections(): boolean {
|
||||||
|
if (this.isArchived) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.organizationId == null) {
|
if (this.organizationId == null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ChangeDetectorRef } from "@angular/core";
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { ReactiveFormsModule } from "@angular/forms";
|
import { ReactiveFormsModule } from "@angular/forms";
|
||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { of } from "rxjs";
|
import { Observable, of } from "rxjs";
|
||||||
|
|
||||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
@@ -13,7 +13,6 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
|||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { UserId } from "@bitwarden/user-core";
|
|
||||||
|
|
||||||
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
||||||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||||
@@ -72,7 +71,7 @@ describe("CipherFormComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should remove archivedDate when user cannot archive and cipher is archived", async () => {
|
it("should remove archivedDate when user cannot archive and cipher is archived", async () => {
|
||||||
mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account);
|
mockAccountService.activeAccount$ = of({ id: "user-id" }) as Observable<Account | null>;
|
||||||
mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||||
mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView());
|
mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView());
|
||||||
|
|
||||||
@@ -154,6 +153,15 @@ describe("CipherFormComponent", () => {
|
|||||||
|
|
||||||
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
|
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears archiveDate on updatedCipherView", async () => {
|
||||||
|
cipherView.archivedDate = new Date();
|
||||||
|
decryptCipher.mockResolvedValue(cipherView);
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("enableFormFields", () => {
|
describe("enableFormFields", () => {
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
|
|
||||||
if (this.config.mode === "clone") {
|
if (this.config.mode === "clone") {
|
||||||
this.updatedCipherView.id = null;
|
this.updatedCipherView.id = null;
|
||||||
|
this.updatedCipherView.archivedDate = null;
|
||||||
|
|
||||||
if (this.updatedCipherView.login) {
|
if (this.updatedCipherView.login) {
|
||||||
this.updatedCipherView.login.fido2Credentials = null;
|
this.updatedCipherView.login.fido2Credentials = null;
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
|||||||
|
|
||||||
export * from "./abstractions/change-login-password.service";
|
export * from "./abstractions/change-login-password.service";
|
||||||
export * from "./services/default-change-login-password.service";
|
export * from "./services/default-change-login-password.service";
|
||||||
|
export * from "./services/archive-cipher-utilities.service";
|
||||||
|
|||||||
122
libs/vault/src/services/archive-cipher-utilities.service.spec.ts
Normal file
122
libs/vault/src/services/archive-cipher-utilities.service.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { ArchiveCipherUtilitiesService } from "./archive-cipher-utilities.service";
|
||||||
|
import { PasswordRepromptService } from "./password-reprompt.service";
|
||||||
|
|
||||||
|
describe("ArchiveCipherUtilitiesService", () => {
|
||||||
|
let service: ArchiveCipherUtilitiesService;
|
||||||
|
|
||||||
|
let cipherArchiveService: MockProxy<CipherArchiveService>;
|
||||||
|
let dialogService: MockProxy<DialogService>;
|
||||||
|
let passwordRepromptService: MockProxy<PasswordRepromptService>;
|
||||||
|
let toastService: MockProxy<ToastService>;
|
||||||
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
let accountService: MockProxy<AccountService>;
|
||||||
|
|
||||||
|
const mockCipher = new CipherView();
|
||||||
|
mockCipher.id = "cipher-id" as CipherId;
|
||||||
|
const mockUserId = "user-id";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cipherArchiveService = mock<CipherArchiveService>();
|
||||||
|
dialogService = mock<DialogService>();
|
||||||
|
passwordRepromptService = mock<PasswordRepromptService>();
|
||||||
|
toastService = mock<ToastService>();
|
||||||
|
i18nService = mock<I18nService>();
|
||||||
|
accountService = mock<AccountService>();
|
||||||
|
|
||||||
|
accountService.activeAccount$ = new BehaviorSubject({ id: mockUserId } as any).asObservable();
|
||||||
|
|
||||||
|
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||||
|
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
|
||||||
|
cipherArchiveService.archiveWithServer.mockResolvedValue(undefined);
|
||||||
|
cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined);
|
||||||
|
i18nService.t.mockImplementation((key) => key);
|
||||||
|
|
||||||
|
service = new ArchiveCipherUtilitiesService(
|
||||||
|
cipherArchiveService,
|
||||||
|
dialogService,
|
||||||
|
passwordRepromptService,
|
||||||
|
toastService,
|
||||||
|
i18nService,
|
||||||
|
accountService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archiveCipher()", () => {
|
||||||
|
it("returns early when confirmation dialog is cancelled", async () => {
|
||||||
|
dialogService.openSimpleDialog.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await service.archiveCipher(mockCipher);
|
||||||
|
|
||||||
|
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalled();
|
||||||
|
expect(cipherArchiveService.archiveWithServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when password reprompt fails", async () => {
|
||||||
|
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await service.archiveCipher(mockCipher);
|
||||||
|
|
||||||
|
expect(cipherArchiveService.archiveWithServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archives cipher and shows success toast when successful", async () => {
|
||||||
|
await service.archiveCipher(mockCipher);
|
||||||
|
|
||||||
|
expect(cipherArchiveService.archiveWithServer).toHaveBeenCalledWith(
|
||||||
|
mockCipher.id,
|
||||||
|
mockUserId,
|
||||||
|
);
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
message: "itemWasSentToArchive",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when archiving fails", async () => {
|
||||||
|
cipherArchiveService.archiveWithServer.mockRejectedValue(new Error("test error"));
|
||||||
|
|
||||||
|
await service.archiveCipher(mockCipher);
|
||||||
|
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
message: "errorOccurred",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unarchiveCipher()", () => {
|
||||||
|
it("unarchives cipher and shows success toast when successful", async () => {
|
||||||
|
await service.unarchiveCipher(mockCipher);
|
||||||
|
|
||||||
|
expect(cipherArchiveService.unarchiveWithServer).toHaveBeenCalledWith(
|
||||||
|
mockCipher.id,
|
||||||
|
mockUserId,
|
||||||
|
);
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
message: "itemWasUnarchived",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when unarchiving fails", async () => {
|
||||||
|
cipherArchiveService.unarchiveWithServer.mockRejectedValue(new Error("test error"));
|
||||||
|
|
||||||
|
await service.unarchiveCipher(mockCipher);
|
||||||
|
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
message: "errorOccurred",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
80
libs/vault/src/services/archive-cipher-utilities.service.ts
Normal file
80
libs/vault/src/services/archive-cipher-utilities.service.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { CipherId } from "@bitwarden/common/types/guid";
|
||||||
|
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { PasswordRepromptService } from "./password-reprompt.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around {@link CipherArchiveService} to provide UI enhancements for archiving/unarchiving ciphers.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: "root" })
|
||||||
|
export class ArchiveCipherUtilitiesService {
|
||||||
|
constructor(
|
||||||
|
private cipherArchiveService: CipherArchiveService,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private passwordRepromptService: PasswordRepromptService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
|
title: { key: "archiveItem" },
|
||||||
|
content: { key: "archiveItemConfirmDesc" },
|
||||||
|
type: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unarchives a cipher */
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user