1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +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:
Jason Ng
2025-10-14 17:41:05 -04:00
committed by GitHub
parent 48c466436e
commit 98af7a13ed
19 changed files with 414 additions and 34 deletions

View File

@@ -558,7 +558,7 @@
"message": "Archive", "message": "Archive",
"description": "Verb" "description": "Verb"
}, },
"unarchive": { "unArchive": {
"message": "Unarchive" "message": "Unarchive"
}, },
"itemsInArchive": { "itemsInArchive": {
@@ -570,11 +570,11 @@
"noItemsInArchiveDesc": { "noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
}, },
"itemSentToArchive": { "itemWasSentToArchive": {
"message": "Item sent to archive" "message": "Item was sent to archive"
}, },
"itemRemovedFromArchive": { "itemUnarchived": {
"message": "Item removed from archive" "message": "Item was unarchived"
}, },
"archiveItem": { "archiveItem": {
"message": "Archive item" "message": "Archive item"

View File

@@ -302,7 +302,7 @@ export class ItemMoreOptionsComponent {
await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId); await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
message: this.i18nService.t("itemSentToArchive"), message: this.i18nService.t("itemWasSentToArchive"),
}); });
} }
} }

View File

@@ -49,7 +49,7 @@
{{ "clone" | i18n }} {{ "clone" | i18n }}
</button> </button>
<button type="button" bitMenuItem (click)="unarchive(cipher)"> <button type="button" bitMenuItem (click)="unarchive(cipher)">
{{ "unarchive" | i18n }} {{ "unArchive" | i18n }}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -133,7 +133,7 @@ export class ArchiveComponent {
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
message: this.i18nService.t("itemRemovedFromArchive"), message: this.i18nService.t("itemUnarchived"),
}); });
} }

View File

@@ -4149,7 +4149,7 @@
"message": "Archive", "message": "Archive",
"description": "Verb" "description": "Verb"
}, },
"unarchive": { "unArchive": {
"message": "Unarchive" "message": "Unarchive"
}, },
"itemsInArchive": { "itemsInArchive": {
@@ -4161,11 +4161,11 @@
"noItemsInArchiveDesc": { "noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
}, },
"itemSentToArchive": { "itemWasSentToArchive": {
"message": "Item sent to archive" "message": "Item was sent to archive"
}, },
"itemRemovedFromArchive": { "itemUnarchived": {
"message": "Item removed from archive" "message": "Item was unarchived"
}, },
"archiveItem": { "archiveItem": {
"message": "Archive item" "message": "Archive item"

View File

@@ -104,7 +104,7 @@
label="{{ 'options' | i18n }}" label="{{ 'options' | i18n }}"
></button> ></button>
<bit-menu #cipherOptions> <bit-menu #cipherOptions>
<ng-container *ngIf="isNotDeletedLoginCipher"> <ng-container *ngIf="isActiveLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy"> <button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }} {{ "copyUsername" | i18n }}
@@ -151,6 +151,20 @@
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }} {{ "eventLogs" | i18n }}
</button> </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"> <button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }} {{ "restore" | i18n }}

View File

@@ -48,6 +48,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
* uses new permission restore logic from PM-15493 * uses new permission restore logic from PM-15493
*/ */
@Input() canRestoreCipher: boolean; @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>>(); @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() { protected get clickAction() {
if (this.decryptionFailure) { if (this.decryptionFailure) {
return "showFailedToDecrypt"; return "showFailedToDecrypt";
@@ -100,7 +122,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return CipherViewLikeUtils.hasAttachments(this.cipher); return CipherViewLikeUtils.hasAttachments(this.cipher);
} }
// Do not show attachments button if:
// item is archived AND user is not premium user
protected get showAttachments() { protected get showAttachments() {
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
return false;
}
return this.canEditCipher || this.hasAttachments; return this.canEditCipher || this.hasAttachments;
} }
@@ -124,7 +151,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return CipherViewLikeUtils.decryptionFailure(this.cipher); return CipherViewLikeUtils.decryptionFailure(this.cipher);
} }
// Do Not show Assign to Collections option if item is archived
protected get showAssignToCollections() { protected get showAssignToCollections() {
if (CipherViewLikeUtils.isArchived(this.cipher)) {
return false;
}
return ( return (
this.organizations?.length && this.organizations?.length &&
this.canAssignCollections && 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() { protected get showClone() {
if (
CipherViewLikeUtils.isArchived(this.cipher) &&
(!this.userCanArchive || this.enforceOrgDataOwnershipPolicy)
) {
return false;
}
return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher); 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; return this.useEvents && this.cipher.organizationId;
} }
protected get isNotDeletedLoginCipher() { protected get isActiveLoginCipher() {
return ( return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login && 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 { protected get showCopyUsername(): boolean {
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
return this.isNotDeletedLoginCipher && usernameCopy; return this.isActiveLoginCipher && usernameCopy;
} }
protected get showCopyPassword(): boolean { protected get showCopyPassword(): boolean {
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); 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 { protected get showCopyTotp(): boolean {
return this.isNotDeletedLoginCipher && this.showTotpCopyButton; return this.isActiveLoginCipher && this.showTotpCopyButton;
} }
protected get showLaunchUri(): boolean { protected get showLaunchUri(): boolean {
return this.isNotDeletedLoginCipher && this.canLaunch; return this.isActiveLoginCipher && this.canLaunch;
} }
protected get isDeletedCanRestore(): boolean { protected get isDeletedCanRestore(): boolean {
@@ -236,6 +277,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
this.onEvent.emit({ type: "viewEvents", item: this.cipher }); 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() { protected restore() {
this.onEvent.emit({ type: "restore", items: [this.cipher] }); this.onEvent.emit({ type: "restore", items: [this.cipher] });
} }

View File

@@ -20,4 +20,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
| { type: "delete"; items: VaultItem<C>[] } | { type: "delete"; items: VaultItem<C>[] }
| { type: "copyField"; item: C; field: "username" | "password" | "totp" } | { type: "copyField"; item: C; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: C[] } | { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] }; | { type: "assignToCollections"; items: C[] }
| { type: "archive"; items: C[] }
| { type: "unarchive"; items: C[] };

View File

@@ -83,6 +83,22 @@
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }} {{ "assignToCollections" | i18n }}
</button> </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 <button
*ngIf="canRestoreSelected$ | async" *ngIf="canRestoreSelected$ | async"
type="button" type="button"
@@ -161,6 +177,8 @@
" "
(checkedToggled)="selection.toggle(item)" (checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)" (onEvent)="event($event)"
[userCanArchive]="userCanArchive"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
></tr> ></tr>
</ng-container> </ng-container>
</ng-template> </ng-template>

View File

@@ -64,6 +64,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
@Input() addAccessStatus: number; @Input() addAccessStatus: number;
@Input() addAccessToggle: boolean; @Input() addAccessToggle: boolean;
@Input() activeCollection: CollectionView | undefined; @Input() activeCollection: CollectionView | undefined;
@Input() userCanArchive: boolean;
@Input() enforceOrgDataOwnershipPolicy: boolean;
private restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$); 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. //@TODO: remove this function when removing the limitItemDeletion$ feature flag.
get showDelete(): boolean { get showDelete(): boolean {
if (this.selection.selected.length === 0) { if (this.selection.selected.length === 0) {
@@ -221,7 +247,17 @@ export class VaultItemsComponent<C extends CipherViewLike> {
} }
get bulkAssignToCollectionsAllowed() { 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 { 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() { protected bulkRestore() {
this.event({ this.event({
type: "restore", type: "restore",

View File

@@ -242,16 +242,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}; };
async buildAllFilters(): Promise<VaultFilterList> { async buildAllFilters(): Promise<VaultFilterList> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$());
const builderFilter = {} as VaultFilterList; const builderFilter = {} as VaultFilterList;
builderFilter.organizationFilter = await this.addOrganizationFilter(); builderFilter.organizationFilter = await this.addOrganizationFilter();
builderFilter.typeFilter = await this.addTypeFilter(); builderFilter.typeFilter = await this.addTypeFilter();
builderFilter.folderFilter = await this.addFolderFilter(); builderFilter.folderFilter = await this.addFolderFilter();
builderFilter.collectionFilter = await this.addCollectionFilter(); builderFilter.collectionFilter = await this.addCollectionFilter();
if ( if (hasArchiveFlag) {
(await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId))) ||
(await firstValueFrom(this.cipherArchiveService.showArchiveVault$(userId)))
) {
builderFilter.archiveFilter = await this.addArchiveFilter(); builderFilter.archiveFilter = await this.addArchiveFilter();
} }
builderFilter.trashFilter = await this.addTrashFilter(); builderFilter.trashFilter = await this.addTrashFilter();

View File

@@ -50,6 +50,8 @@
[useEvents]="false" [useEvents]="false"
[showAdminActions]="false" [showAdminActions]="false"
[showBulkAddToCollections]="true" [showBulkAddToCollections]="true"
[userCanArchive]="userCanArchive$ | async"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy$ | async"
(onEvent)="onVaultItemsEvent($event)" (onEvent)="onVaultItemsEvent($event)"
> >
</app-vault-items> </app-vault-items>

View File

@@ -46,6 +46,8 @@ import {
getOrganizationById, getOrganizationById,
OrganizationService, OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } 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 { 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";
@@ -210,7 +212,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
.pipe(map((a) => a?.id)) .pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id))); .pipe(switchMap((id) => this.organizationService.organizations$(id)));
private userCanArchive$ = this.accountService.activeAccount$.pipe( protected userCanArchive$ = this.accountService.activeAccount$.pipe(
getUserId, getUserId,
switchMap((userId) => { switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId); return this.cipherArchiveService.userCanArchive$(userId);
@@ -256,7 +258,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}, },
archive: { archive: {
title: "noItemsInArchive", title: "noItemsInArchive",
description: "archivedItemsDescription", description: "noItemsInArchiveDesc",
icon: this.itemTypesIcon, 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( constructor(
private syncService: SyncService, private syncService: SyncService,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -307,6 +318,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private restrictedItemTypesService: RestrictedItemTypesService, private restrictedItemTypesService: RestrictedItemTypesService,
private cipherArchiveService: CipherArchiveService, private cipherArchiveService: CipherArchiveService,
private organizationWarningsService: OrganizationWarningsService, private organizationWarningsService: OrganizationWarningsService,
private policyService: PolicyService,
private unifiedUpgradePromptService: UnifiedUpgradePromptService, private unifiedUpgradePromptService: UnifiedUpgradePromptService,
) {} ) {}
@@ -405,7 +417,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
allowedCiphers$, allowedCiphers$,
filter$, filter$,
this.currentSearchText$, this.currentSearchText$,
this.userCanArchive$, this.cipherArchiveService.hasArchiveFlagEnabled$(),
]).pipe( ]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => { concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => {
@@ -653,12 +665,140 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
case "assignToCollections": case "assignToCollections":
await this.bulkAssignToCollections(event.items); await this.bulkAssignToCollections(event.items);
break; 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 { } finally {
this.processingEvent = false; 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) { async applyOrganizationFilter(orgId: string) {
if (orgId == null) { if (orgId == null) {
orgId = "MyVault"; orgId = "MyVault";

View File

@@ -11298,12 +11298,47 @@
"message": "Archive", "message": "Archive",
"description": "Verb" "description": "Verb"
}, },
"unArchive": {
"message": "Unarchive"
},
"itemsInArchive": {
"message": "Items in archive"
},
"noItemsInArchive": { "noItemsInArchive": {
"message": "No items in archive" "message": "No items in archive"
}, },
"archivedItemsDescription": { "noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." "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": { "businessUnit": {
"message": "Business Unit" "message": "Business Unit"
}, },

View File

@@ -4,6 +4,7 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
export abstract class CipherArchiveService { export abstract class CipherArchiveService {
abstract hasArchiveFlagEnabled$(): Observable<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>; abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>; abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract showArchiveVault$(userId: UserId): Observable<boolean>; abstract showArchiveVault$(userId: UserId): Observable<boolean>;

View File

@@ -27,6 +27,10 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
private configService: ConfigService, private configService: ConfigService,
) {} ) {}
hasArchiveFlagEnabled$(): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive);
}
/** /**
* Observable that contains the list of ciphers that have been archived. * Observable that contains the list of ciphers that have been archived.
*/ */

View File

@@ -10,7 +10,7 @@ import {
moduleMetadata, moduleMetadata,
StoryObj, StoryObj,
} from "@storybook/angular"; } 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. // 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 // eslint-disable-next-line no-restricted-imports
@@ -155,6 +155,20 @@ export default {
} as NudgeStatus), } as NudgeStatus),
}, },
}, },
{
provide: CipherArchiveService,
useValue: {
userCanArchive$: of(false),
},
},
{
provide: AccountService,
useValue: {
activeAccount$: of({
name: "User 1",
}),
} as Partial<AccountService>,
},
{ {
provide: CipherFormService, provide: CipherFormService,
useClass: TestAddEditFormService, useClass: TestAddEditFormService,

View File

@@ -2,14 +2,18 @@ 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 { 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { 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";
@@ -23,6 +27,10 @@ describe("CipherFormComponent", () => {
const decryptCipher = jest.fn().mockResolvedValue(new CipherView()); const decryptCipher = jest.fn().mockResolvedValue(new CipherView());
const mockAccountService = mock<AccountService>();
const mockCipherArchiveService = mock<CipherArchiveService>();
const mockAddEditFormService = { saveCipher: jest.fn(), decryptCipher };
beforeEach(async () => { beforeEach(async () => {
decryptCipher.mockClear(); decryptCipher.mockClear();
@@ -32,13 +40,15 @@ describe("CipherFormComponent", () => {
{ provide: ChangeDetectorRef, useValue: {} }, { provide: ChangeDetectorRef, useValue: {} },
{ provide: I18nService, useValue: { t: (key: string) => key } }, { provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: ToastService, useValue: { showToast: jest.fn() } }, { provide: ToastService, useValue: { showToast: jest.fn() } },
{ provide: CipherFormService, useValue: { saveCipher: jest.fn(), decryptCipher } }, { provide: CipherFormService, useValue: mockAddEditFormService },
{ {
provide: CipherFormCacheService, provide: CipherFormCacheService,
useValue: { init: jest.fn(), getCachedCipherView: jest.fn() }, useValue: { init: jest.fn(), getCachedCipherView: jest.fn() },
}, },
{ provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } }, { provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } },
{ provide: ConfigService, useValue: mock<ConfigService>() }, { provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: AccountService, useValue: mockAccountService },
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
], ],
}).compileComponents(); }).compileComponents();
}); });
@@ -53,6 +63,29 @@ describe("CipherFormComponent", () => {
expect(component).toBeTruthy(); 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", () => { describe("website", () => {
it("should return null if updatedCipherView is null", () => { it("should return null if updatedCipherView is null", () => {
component["updatedCipherView"] = null as any; component["updatedCipherView"] = null as any;

View File

@@ -17,9 +17,12 @@ import {
} from "@angular/core"; } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; 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 { 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 { CipherType, SecureNoteType } 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 { import {
@@ -301,6 +304,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
private i18nService: I18nService, private i18nService: I18nService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private cipherFormCacheService: CipherFormCacheService, 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( const savedCipher = await this.addEditFormService.saveCipher(
this.updatedCipherView, this.updatedCipherView,
this.config, this.config,