mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +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": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"itemWasUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
|
||||
@@ -46,7 +46,23 @@
|
||||
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</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
|
||||
type="button"
|
||||
(click)="delete()"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Input, Output, EventEmitter, Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
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 { 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-item-footer",
|
||||
templateUrl: "item-footer.component.html",
|
||||
imports: [ButtonModule, CommonModule, JslibModule],
|
||||
})
|
||||
export class ItemFooterComponent implements OnInit {
|
||||
export class ItemFooterComponent implements OnInit, OnChanges {
|
||||
@Input({ required: true }) cipher: CipherView = new CipherView();
|
||||
@Input() collectionId: string | null = null;
|
||||
@Input({ required: true }) action: string = "view";
|
||||
@@ -30,11 +40,15 @@ export class ItemFooterComponent implements OnInit {
|
||||
@Output() onDelete = new EventEmitter<CipherView>();
|
||||
@Output() onRestore = new EventEmitter<CipherView>();
|
||||
@Output() onCancel = new EventEmitter<CipherView>();
|
||||
@Output() onArchiveToggle = new EventEmitter<CipherView>();
|
||||
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
|
||||
|
||||
activeUserId: UserId | null = null;
|
||||
passwordReprompted: boolean = false;
|
||||
|
||||
protected showArchiveButton = false;
|
||||
protected showUnarchiveButton = false;
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected dialogService: DialogService,
|
||||
@@ -44,11 +58,20 @@ export class ItemFooterComponent implements OnInit {
|
||||
protected toastService: ToastService,
|
||||
protected i18nService: I18nService,
|
||||
protected logService: LogService,
|
||||
protected cipherArchiveService: CipherArchiveService,
|
||||
protected archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.passwordReprompted = this.masterPasswordAlreadyPrompted;
|
||||
await this.checkArchiveState();
|
||||
}
|
||||
|
||||
async ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.cipher) {
|
||||
await this.checkArchiveState();
|
||||
}
|
||||
}
|
||||
|
||||
async clone() {
|
||||
@@ -76,6 +99,14 @@ export class ItemFooterComponent implements OnInit {
|
||||
this.onEdit.emit(this.cipher);
|
||||
}
|
||||
|
||||
protected get hasFooterAction() {
|
||||
return (
|
||||
this.showArchiveButton ||
|
||||
this.showUnarchiveButton ||
|
||||
(this.cipher.permissions?.delete && (this.action === "edit" || this.action === "view"))
|
||||
);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.onCancel.emit(this.cipher);
|
||||
}
|
||||
@@ -151,4 +182,36 @@ export class ItemFooterComponent implements OnInit {
|
||||
|
||||
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>
|
||||
</span>
|
||||
</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
|
||||
class="filter-option"
|
||||
*ngIf="!hideTrash"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
class="filter"
|
||||
[hideFavorites]="hideFavorites"
|
||||
[hideTrash]="hideTrash"
|
||||
[hideArchive]="!showArchiveVaultFilter"
|
||||
[activeFilter]="activeFilter"
|
||||
(onFilterChange)="applyFilter($event)"
|
||||
></app-status-filter>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { distinctUntilChanged, debounceTime } from "rxjs";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
|
||||
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
@@ -33,8 +34,9 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
|
||||
cipherService: CipherService,
|
||||
accountService: AccountService,
|
||||
restrictedItemTypesService: RestrictedItemTypesService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
super(searchService, cipherService, accountService, restrictedItemTypesService);
|
||||
super(searchService, cipherService, accountService, restrictedItemTypesService, configService);
|
||||
|
||||
this.searchBarService.searchText$
|
||||
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
(onClone)="cloneCipher($event)"
|
||||
(onDelete)="deleteCipher()"
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
|
||||
@@ -20,6 +20,8 @@ import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { 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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/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 { SyncService } from "@bitwarden/common/platform/sync";
|
||||
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 { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
@@ -74,6 +77,7 @@ import {
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
CipherFormComponent,
|
||||
ArchiveCipherUtilitiesService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { NavComponent } from "../../../app/layout/nav.component";
|
||||
@@ -211,6 +215,9 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
private folderService: FolderService,
|
||||
private configService: ConfigService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private policyService: PolicyService,
|
||||
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -490,6 +497,12 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
async viewCipherMenu(c: CipherViewLike) {
|
||||
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[] = [
|
||||
{
|
||||
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({
|
||||
label: this.i18nService.t("clone"),
|
||||
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) {
|
||||
case CipherType.Login:
|
||||
if (
|
||||
@@ -723,8 +760,6 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
|
||||
await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {});
|
||||
await this.go().catch(() => {});
|
||||
await this.vaultItemsComponent?.refresh().catch(() => {});
|
||||
}
|
||||
@@ -757,7 +792,11 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
);
|
||||
this.activeFilter = vaultFilter;
|
||||
await this.vaultItemsComponent
|
||||
?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash")
|
||||
?.reload(
|
||||
this.activeFilter.buildFilter(),
|
||||
vaultFilter.status === "trash",
|
||||
vaultFilter.status === "archive",
|
||||
)
|
||||
.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 {
|
||||
return (
|
||||
(this.action === "add" || this.action === "edit" || this.action === "clone") &&
|
||||
|
||||
Reference in New Issue
Block a user