mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[PM-19152] Archive in Web (#16686)
* archive and unarchive an individual item * bulk archive and unachive * updates to text strings for archive empty state and tooltips * update translation keys to have an archive verb and noun differentiation * if premium member loses premium and has archive items. apply filter changes, and item more option changes * updating unArchive text * unarchive an archived item on edit if user loses premium * updates for unarchive btn, refactor archive flag for less churn * add services to cipher form stories * add refresh to archive calls in vault, update bulk archive copy * Do not show archive ability for deleted items * add archive check for login menu actions * remove assign to collections for archive filter * update bulk success message * add error handling for archive methods * fix null reference check * add unarchive icon --------- Co-authored-by: Nick Krantz <nick@livefront.com>
This commit is contained in:
@@ -558,7 +558,7 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unarchive": {
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
@@ -570,11 +570,11 @@
|
||||
"noItemsInArchiveDesc": {
|
||||
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
|
||||
},
|
||||
"itemSentToArchive": {
|
||||
"message": "Item sent to archive"
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemRemovedFromArchive": {
|
||||
"message": "Item removed from archive"
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Archive item"
|
||||
|
||||
@@ -302,7 +302,7 @@ export class ItemMoreOptionsComponent {
|
||||
await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemSentToArchive"),
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="unarchive(cipher)">
|
||||
{{ "unarchive" | i18n }}
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -133,7 +133,7 @@ export class ArchiveComponent {
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemRemovedFromArchive"),
|
||||
message: this.i18nService.t("itemUnarchived"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4149,7 +4149,7 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unarchive": {
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
@@ -4161,11 +4161,11 @@
|
||||
"noItemsInArchiveDesc": {
|
||||
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
|
||||
},
|
||||
"itemSentToArchive": {
|
||||
"message": "Item sent to archive"
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemRemovedFromArchive": {
|
||||
"message": "Item removed from archive"
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Archive item"
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #cipherOptions>
|
||||
<ng-container *ngIf="isNotDeletedLoginCipher">
|
||||
<ng-container *ngIf="isActiveLoginCipher">
|
||||
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyUsername" | i18n }}
|
||||
@@ -151,6 +151,20 @@
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
@if (showArchiveButton) {
|
||||
<button bitMenuItem (click)="archive()" type="button">
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showUnArchiveButton) {
|
||||
<button bitMenuItem (click)="unarchive()" type="button">
|
||||
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
|
||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||
{{ "restore" | i18n }}
|
||||
|
||||
@@ -48,6 +48,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
* uses new permission restore logic from PM-15493
|
||||
*/
|
||||
@Input() canRestoreCipher: boolean;
|
||||
/**
|
||||
* user has archive permissions
|
||||
*/
|
||||
@Input() userCanArchive: boolean;
|
||||
/**
|
||||
* Enforge Org Data Ownership Policy Status
|
||||
*/
|
||||
@Input() enforceOrgDataOwnershipPolicy: boolean;
|
||||
|
||||
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
|
||||
|
||||
@@ -76,6 +84,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
}
|
||||
}
|
||||
|
||||
protected get showArchiveButton() {
|
||||
return (
|
||||
this.userCanArchive &&
|
||||
!CipherViewLikeUtils.isArchived(this.cipher) &&
|
||||
!CipherViewLikeUtils.isDeleted(this.cipher) &&
|
||||
!this.cipher.organizationId
|
||||
);
|
||||
}
|
||||
|
||||
// If item is archived always show unarchive button, even if user is not premium
|
||||
protected get showUnArchiveButton() {
|
||||
return CipherViewLikeUtils.isArchived(this.cipher);
|
||||
}
|
||||
|
||||
protected get clickAction() {
|
||||
if (this.decryptionFailure) {
|
||||
return "showFailedToDecrypt";
|
||||
@@ -100,7 +122,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return CipherViewLikeUtils.hasAttachments(this.cipher);
|
||||
}
|
||||
|
||||
// Do not show attachments button if:
|
||||
// item is archived AND user is not premium user
|
||||
protected get showAttachments() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
|
||||
return false;
|
||||
}
|
||||
return this.canEditCipher || this.hasAttachments;
|
||||
}
|
||||
|
||||
@@ -124,7 +151,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return CipherViewLikeUtils.decryptionFailure(this.cipher);
|
||||
}
|
||||
|
||||
// Do Not show Assign to Collections option if item is archived
|
||||
protected get showAssignToCollections() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.organizations?.length &&
|
||||
this.canAssignCollections &&
|
||||
@@ -132,7 +163,16 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
);
|
||||
}
|
||||
|
||||
// Do NOT show clone option if:
|
||||
// item is archived AND user is not premium user
|
||||
// item is archived AND enforce org data ownership policy is on
|
||||
protected get showClone() {
|
||||
if (
|
||||
CipherViewLikeUtils.isArchived(this.cipher) &&
|
||||
(!this.userCanArchive || this.enforceOrgDataOwnershipPolicy)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher);
|
||||
}
|
||||
|
||||
@@ -140,10 +180,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return this.useEvents && this.cipher.organizationId;
|
||||
}
|
||||
|
||||
protected get isNotDeletedLoginCipher() {
|
||||
protected get isActiveLoginCipher() {
|
||||
return (
|
||||
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
|
||||
!CipherViewLikeUtils.isDeleted(this.cipher)
|
||||
!CipherViewLikeUtils.isDeleted(this.cipher) &&
|
||||
!CipherViewLikeUtils.isArchived(this.cipher)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,20 +232,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
|
||||
protected get showCopyUsername(): boolean {
|
||||
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
|
||||
return this.isNotDeletedLoginCipher && usernameCopy;
|
||||
return this.isActiveLoginCipher && usernameCopy;
|
||||
}
|
||||
|
||||
protected get showCopyPassword(): boolean {
|
||||
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
|
||||
return this.isNotDeletedLoginCipher && this.cipher.viewPassword && passwordCopy;
|
||||
return this.isActiveLoginCipher && this.cipher.viewPassword && passwordCopy;
|
||||
}
|
||||
|
||||
protected get showCopyTotp(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.showTotpCopyButton;
|
||||
return this.isActiveLoginCipher && this.showTotpCopyButton;
|
||||
}
|
||||
|
||||
protected get showLaunchUri(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.canLaunch;
|
||||
return this.isActiveLoginCipher && this.canLaunch;
|
||||
}
|
||||
|
||||
protected get isDeletedCanRestore(): boolean {
|
||||
@@ -236,6 +277,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
this.onEvent.emit({ type: "viewEvents", item: this.cipher });
|
||||
}
|
||||
|
||||
protected archive() {
|
||||
this.onEvent.emit({ type: "archive", items: [this.cipher] });
|
||||
}
|
||||
|
||||
protected unarchive() {
|
||||
this.onEvent.emit({ type: "unarchive", items: [this.cipher] });
|
||||
}
|
||||
|
||||
protected restore() {
|
||||
this.onEvent.emit({ type: "restore", items: [this.cipher] });
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
|
||||
| { type: "delete"; items: VaultItem<C>[] }
|
||||
| { type: "copyField"; item: C; field: "username" | "password" | "totp" }
|
||||
| { type: "moveToFolder"; items: C[] }
|
||||
| { type: "assignToCollections"; items: C[] };
|
||||
| { type: "assignToCollections"; items: C[] }
|
||||
| { type: "archive"; items: C[] }
|
||||
| { type: "unarchive"; items: C[] };
|
||||
|
||||
@@ -83,6 +83,22 @@
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
|
||||
<button *ngIf="bulkArchiveAllowed" type="button" bitMenuItem (click)="bulkArchive()">
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="bulkUnarchiveAllowed"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkUnarchive()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="canRestoreSelected$ | async"
|
||||
type="button"
|
||||
@@ -161,6 +177,8 @@
|
||||
"
|
||||
(checkedToggled)="selection.toggle(item)"
|
||||
(onEvent)="event($event)"
|
||||
[userCanArchive]="userCanArchive"
|
||||
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
|
||||
></tr>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@@ -64,6 +64,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
@Input() addAccessStatus: number;
|
||||
@Input() addAccessToggle: boolean;
|
||||
@Input() activeCollection: CollectionView | undefined;
|
||||
@Input() userCanArchive: boolean;
|
||||
@Input() enforceOrgDataOwnershipPolicy: boolean;
|
||||
|
||||
private restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
|
||||
|
||||
@@ -191,6 +193,30 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
);
|
||||
}
|
||||
|
||||
get bulkArchiveAllowed() {
|
||||
if (this.selection.selected.length === 0 || !this.userCanArchive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.userCanArchive &&
|
||||
!this.selection.selected.find(
|
||||
(item) => item.cipher && (item.cipher.organizationId || item.cipher.archivedDate),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Bulk Unarchive button should appear for Archive vault even if user does not have archive permissions
|
||||
get bulkUnarchiveAllowed() {
|
||||
if (this.selection.selected.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.selection.selected.find(
|
||||
(item) => !item.cipher.archivedDate || item.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
//@TODO: remove this function when removing the limitItemDeletion$ feature flag.
|
||||
get showDelete(): boolean {
|
||||
if (this.selection.selected.length === 0) {
|
||||
@@ -221,7 +247,17 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
}
|
||||
|
||||
get bulkAssignToCollectionsAllowed() {
|
||||
return this.showBulkAddToCollections && this.ciphers.length > 0;
|
||||
return (
|
||||
this.showBulkAddToCollections &&
|
||||
this.ciphers.length > 0 &&
|
||||
!this.anySelectedCiphersAreArchived
|
||||
);
|
||||
}
|
||||
|
||||
get anySelectedCiphersAreArchived() {
|
||||
return this.selection.selected.some(
|
||||
(item) => item.cipher && CipherViewLikeUtils.isArchived(item.cipher),
|
||||
);
|
||||
}
|
||||
|
||||
protected canEditCollection(collection: CollectionView): boolean {
|
||||
@@ -270,6 +306,24 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
});
|
||||
}
|
||||
|
||||
protected bulkArchive() {
|
||||
this.event({
|
||||
type: "archive",
|
||||
items: this.selection.selected
|
||||
.filter((item) => item.cipher !== undefined)
|
||||
.map((item) => item.cipher),
|
||||
});
|
||||
}
|
||||
|
||||
protected bulkUnarchive() {
|
||||
this.event({
|
||||
type: "unarchive",
|
||||
items: this.selection.selected
|
||||
.filter((item) => item.cipher !== undefined)
|
||||
.map((item) => item.cipher),
|
||||
});
|
||||
}
|
||||
|
||||
protected bulkRestore() {
|
||||
this.event({
|
||||
type: "restore",
|
||||
|
||||
@@ -242,16 +242,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
async buildAllFilters(): Promise<VaultFilterList> {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$());
|
||||
const builderFilter = {} as VaultFilterList;
|
||||
builderFilter.organizationFilter = await this.addOrganizationFilter();
|
||||
builderFilter.typeFilter = await this.addTypeFilter();
|
||||
builderFilter.folderFilter = await this.addFolderFilter();
|
||||
builderFilter.collectionFilter = await this.addCollectionFilter();
|
||||
if (
|
||||
(await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId))) ||
|
||||
(await firstValueFrom(this.cipherArchiveService.showArchiveVault$(userId)))
|
||||
) {
|
||||
if (hasArchiveFlag) {
|
||||
builderFilter.archiveFilter = await this.addArchiveFilter();
|
||||
}
|
||||
builderFilter.trashFilter = await this.addTrashFilter();
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
[useEvents]="false"
|
||||
[showAdminActions]="false"
|
||||
[showBulkAddToCollections]="true"
|
||||
[userCanArchive]="userCanArchive$ | async"
|
||||
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy$ | async"
|
||||
(onEvent)="onVaultItemsEvent($event)"
|
||||
>
|
||||
</app-vault-items>
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
getOrganizationById,
|
||||
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -210,7 +212,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
.pipe(map((a) => a?.id))
|
||||
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
|
||||
|
||||
private userCanArchive$ = this.accountService.activeAccount$.pipe(
|
||||
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => {
|
||||
return this.cipherArchiveService.userCanArchive$(userId);
|
||||
@@ -256,7 +258,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
},
|
||||
archive: {
|
||||
title: "noItemsInArchive",
|
||||
description: "archivedItemsDescription",
|
||||
description: "noItemsInArchiveDesc",
|
||||
icon: this.itemTypesIcon,
|
||||
},
|
||||
};
|
||||
@@ -275,6 +277,15 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}),
|
||||
);
|
||||
|
||||
protected enforceOrgDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||
),
|
||||
);
|
||||
|
||||
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
private route: ActivatedRoute,
|
||||
@@ -307,6 +318,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
private restrictedItemTypesService: RestrictedItemTypesService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private policyService: PolicyService,
|
||||
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
|
||||
) {}
|
||||
|
||||
@@ -405,7 +417,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
allowedCiphers$,
|
||||
filter$,
|
||||
this.currentSearchText$,
|
||||
this.userCanArchive$,
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||
]).pipe(
|
||||
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
|
||||
concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => {
|
||||
@@ -653,12 +665,140 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
case "assignToCollections":
|
||||
await this.bulkAssignToCollections(event.items);
|
||||
break;
|
||||
case "archive":
|
||||
if (event.items.length === 1) {
|
||||
await this.archive(event.items[0]);
|
||||
} else {
|
||||
await this.bulkArchive(event.items);
|
||||
}
|
||||
break;
|
||||
case "unarchive":
|
||||
if (event.items.length === 1) {
|
||||
await this.unarchive(event.items[0]);
|
||||
} else {
|
||||
await this.bulkUnarchive(event.items);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
this.processingEvent = false;
|
||||
}
|
||||
}
|
||||
|
||||
async archive(cipher: C) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveItem" },
|
||||
content: { key: "archiveItemConfirmDesc" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
try {
|
||||
await this.cipherArchiveService.archiveWithServer(cipher.id as CipherId, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error archiving cipher", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async bulkArchive(ciphers: C[]) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveBulkItems" },
|
||||
content: { key: "archiveBulkItemsConfirmDesc" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
const cipherIds = ciphers.map((c) => c.id as CipherId);
|
||||
try {
|
||||
await this.cipherArchiveService.archiveWithServer(cipherIds as CipherId[], activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemsWereSentToArchive"),
|
||||
});
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error archiving ciphers", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unarchive(cipher: C) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
|
||||
try {
|
||||
await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemUnarchived"),
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error unarchiving cipher", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async bulkUnarchive(ciphers: C[]) {
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
const cipherIds = ciphers.map((c) => c.id as CipherId);
|
||||
try {
|
||||
await this.cipherArchiveService.unarchiveWithServer(cipherIds as CipherId[], activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkUnarchiveItems"),
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error unarchiving ciphers", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async applyOrganizationFilter(orgId: string) {
|
||||
if (orgId == null) {
|
||||
orgId = "MyVault";
|
||||
|
||||
@@ -11298,12 +11298,47 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Items in archive"
|
||||
},
|
||||
"noItemsInArchive": {
|
||||
"message": "No items in archive"
|
||||
},
|
||||
"archivedItemsDescription": {
|
||||
"noItemsInArchiveDesc": {
|
||||
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
|
||||
},
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemsWereSentToArchive": {
|
||||
"message": "Items were sent to archive"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"bulkArchiveItems": {
|
||||
"message": "Items archived"
|
||||
},
|
||||
"bulkUnarchiveItems": {
|
||||
"message": "Items unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Archive item",
|
||||
"description": "Verb"
|
||||
},
|
||||
"archiveItemConfirmDesc": {
|
||||
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
|
||||
},
|
||||
"archiveBulkItems": {
|
||||
"message": "Archive items",
|
||||
"description": "Verb"
|
||||
},
|
||||
"archiveBulkItemsConfirmDesc": {
|
||||
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive these items?"
|
||||
},
|
||||
"businessUnit": {
|
||||
"message": "Business Unit"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class CipherArchiveService {
|
||||
abstract hasArchiveFlagEnabled$(): Observable<boolean>;
|
||||
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
|
||||
abstract userCanArchive$(userId: UserId): Observable<boolean>;
|
||||
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
|
||||
|
||||
@@ -27,6 +27,10 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
hasArchiveFlagEnabled$(): Observable<boolean> {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable that contains the list of ciphers that have been archived.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
moduleMetadata,
|
||||
StoryObj,
|
||||
} from "@storybook/angular";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -155,6 +155,20 @@ export default {
|
||||
} as NudgeStatus),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CipherArchiveService,
|
||||
useValue: {
|
||||
userCanArchive$: of(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
name: "User 1",
|
||||
}),
|
||||
} as Partial<AccountService>,
|
||||
},
|
||||
{
|
||||
provide: CipherFormService,
|
||||
useClass: TestAddEditFormService,
|
||||
|
||||
@@ -2,14 +2,18 @@ import { ChangeDetectorRef } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
||||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||
@@ -23,6 +27,10 @@ describe("CipherFormComponent", () => {
|
||||
|
||||
const decryptCipher = jest.fn().mockResolvedValue(new CipherView());
|
||||
|
||||
const mockAccountService = mock<AccountService>();
|
||||
const mockCipherArchiveService = mock<CipherArchiveService>();
|
||||
const mockAddEditFormService = { saveCipher: jest.fn(), decryptCipher };
|
||||
|
||||
beforeEach(async () => {
|
||||
decryptCipher.mockClear();
|
||||
|
||||
@@ -32,13 +40,15 @@ describe("CipherFormComponent", () => {
|
||||
{ provide: ChangeDetectorRef, useValue: {} },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: ToastService, useValue: { showToast: jest.fn() } },
|
||||
{ provide: CipherFormService, useValue: { saveCipher: jest.fn(), decryptCipher } },
|
||||
{ provide: CipherFormService, useValue: mockAddEditFormService },
|
||||
{
|
||||
provide: CipherFormCacheService,
|
||||
useValue: { init: jest.fn(), getCachedCipherView: jest.fn() },
|
||||
},
|
||||
{ provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
@@ -53,6 +63,29 @@ describe("CipherFormComponent", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("submit", () => {
|
||||
beforeEach(() => {
|
||||
component.config = { mode: "edit" } as CipherFormConfig;
|
||||
|
||||
component["updatedCipherView"] = new CipherView();
|
||||
component["updatedCipherView"].archivedDate = new Date();
|
||||
});
|
||||
|
||||
it("should remove archivedDate when user cannot archive and cipher is archived", async () => {
|
||||
mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account);
|
||||
mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView());
|
||||
|
||||
const originalArchivedDate = component["updatedCipherView"]?.archivedDate;
|
||||
expect(originalArchivedDate).not.toBeNull();
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||
expect(mockCipherArchiveService.userCanArchive$).toHaveBeenCalledWith("user-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("website", () => {
|
||||
it("should return null if updatedCipherView is null", () => {
|
||||
component["updatedCipherView"] = null as any;
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
@@ -301,6 +304,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
private i18nService: I18nService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private cipherFormCacheService: CipherFormCacheService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -342,6 +347,18 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
}
|
||||
}
|
||||
|
||||
const userCanArchive = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
|
||||
),
|
||||
);
|
||||
|
||||
// If the item is archived but user has lost archive permissions, unarchive the item.
|
||||
if (!userCanArchive && this.updatedCipherView.archivedDate) {
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
}
|
||||
|
||||
const savedCipher = await this.addEditFormService.saveCipher(
|
||||
this.updatedCipherView,
|
||||
this.config,
|
||||
|
||||
Reference in New Issue
Block a user