1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 10:13:31 +00:00

[PM-8125] Move Trash to the Vault settings page (#10736)

* created trash and trash container component

* added trash to vault settings

created observable to get deleted ciphers

* export icon

added locales

* remove edit and delete footver from trash view cipher

* Added helper text when viewing deleted ciphers

* prevent premature access of isDeleted from the cipher object

* simplified the condition to show the edit button

* return cipherView for deletedCiphers$ since that is what is used in the component

* changed section header to h6

* added routing animation

* Added restore to footer
This commit is contained in:
SmithThe4th
2024-08-30 15:46:26 -04:00
committed by GitHub
parent 963e339e4f
commit 5a73639946
15 changed files with 356 additions and 7 deletions

View File

@@ -4296,5 +4296,26 @@
},
"additionalContentAvailable": {
"message": "Additional content is available"
},
"itemsInTrash": {
"message": "Items in trash"
},
"noItemsInTrash": {
"message": "No items in trash"
},
"noItemsInTrashDesc": {
"message": "Items you delete will appear here and be permanently deleted after 30 days"
},
"trashWarning": {
"message": "Items that have been in trash more than 30 days will automatically be deleted"
},
"restore": {
"message": "Restore"
},
"deleteForever": {
"message": "Delete forever"
},
"noEditPermissions": {
"message": "You don't have permission to edit this item"
}
}

View File

@@ -199,6 +199,9 @@ export const routerTransition = trigger("routerTransition", [
transition("vault-settings => sync", inSlideLeft),
transition("sync => vault-settings", outSlideRight),
transition("vault-settings => trash", inSlideLeft),
transition("trash => vault-settings", outSlideRight),
// Appearance settings
transition("tabs => appearance", inSlideLeft),
transition("appearance => tabs", outSlideRight),

View File

@@ -91,6 +91,7 @@ import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.
import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component";
import { FoldersComponent } from "../vault/popup/settings/folders.component";
import { SyncComponent } from "../vault/popup/settings/sync.component";
import { TrashComponent } from "../vault/popup/settings/trash.component";
import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component";
import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component";
@@ -496,6 +497,12 @@ const routes: Routes = [
component: AccountSwitcherComponent,
data: { state: "account-switcher", doNotSaveUrl: true },
},
{
path: "trash",
component: TrashComponent,
canActivate: [authGuard],
data: { state: "trash" },
},
];
@Injectable()

View File

@@ -5,13 +5,30 @@
<app-cipher-view *ngIf="cipher" [cipher]="cipher"></app-cipher-view>
<popup-footer slot="footer">
<button buttonType="primary" type="button" bitButton (click)="editCipher()">
<popup-footer slot="footer" *ngIf="showFooter()">
<button
*ngIf="!cipher.isDeleted"
buttonType="primary"
type="button"
bitButton
(click)="editCipher()"
>
{{ "edit" | i18n }}
</button>
<button
*ngIf="cipher.isDeleted && cipher.edit"
buttonType="primary"
type="button"
bitButton
[bitAction]="restore"
>
{{ "restore" | i18n }}
</button>
<button
slot="end"
*ngIf="cipher && cipher.edit"
*ngIf="cipher.edit"
[bitAction]="delete"
type="button"
buttonType="danger"

View File

@@ -162,9 +162,28 @@ export class ViewV2Component {
return true;
};
restore = async (): Promise<void> => {
try {
await this.cipherService.restoreWithServer(this.cipher.id);
} catch (e) {
this.logService.error(e);
}
await this.router.navigate(["/vault"]);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
});
};
protected deleteCipher() {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id)
: this.cipherService.softDeleteWithServer(this.cipher.id);
}
protected showFooter(): boolean {
return this.cipher && (!this.cipher.isDeleted || (this.cipher.isDeleted && this.cipher.edit));
}
}

View File

@@ -358,6 +358,24 @@ describe("VaultPopupItemsService", () => {
});
});
describe("deletedCiphers$", () => {
it("should return deleted ciphers", (done) => {
const ciphers = [
{ id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true },
{ id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true },
{ id: "4", type: CipherType.Login, name: "Login 4", isDeleted: false },
] as CipherView[];
cipherServiceMock.getAllDecrypted.mockResolvedValue(ciphers);
service.deletedCiphers$.subscribe((deletedCiphers) => {
expect(deletedCiphers.length).toBe(3);
done();
});
});
});
describe("hasFilterApplied$", () => {
it("should return true if the search term provided is searchable", (done) => {
searchService.isSearchable.mockImplementation(async () => true);

View File

@@ -76,7 +76,7 @@ export class VaultPopupItemsService {
* Observable that contains the list of all decrypted ciphers.
* @private
*/
private _cipherList$: Observable<PopupCipherView[]> = merge(
private _allDecryptedCiphers$: Observable<CipherView[]> = merge(
this.cipherService.ciphers$,
this.cipherService.localData$,
).pipe(
@@ -84,6 +84,10 @@ export class VaultPopupItemsService {
tap(() => this._ciphersLoading$.next()),
waitUntilSync(this.syncService),
switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())),
shareReplay({ refCount: true, bufferSize: 1 }),
);
private _activeCipherList$: Observable<PopupCipherView[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) =>
combineLatest([
this.organizationService.organizations$,
@@ -105,11 +109,10 @@ export class VaultPopupItemsService {
}),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([
this._cipherList$,
this._activeCipherList$,
this._searchText$,
this.vaultPopupListFiltersService.filterFunction$,
]).pipe(
@@ -208,7 +211,9 @@ export class VaultPopupItemsService {
/**
* Observable that indicates whether the user's vault is empty.
*/
emptyVault$: Observable<boolean> = this._cipherList$.pipe(map((ciphers) => !ciphers.length));
emptyVault$: Observable<boolean> = this._activeCipherList$.pipe(
map((ciphers) => !ciphers.length),
);
/**
* Observable that indicates whether there are no ciphers to show with the current filter.
@@ -232,6 +237,14 @@ export class VaultPopupItemsService {
}),
);
/**
* Observable that contains the list of ciphers that have been deleted.
*/
deletedCiphers$: Observable<CipherView[]> = this._allDecryptedCiphers$.pipe(
map((ciphers) => ciphers.filter((c) => c.isDeleted)),
shareReplay({ refCount: false, bufferSize: 1 }),
);
constructor(
private cipherService: CipherService,
private vaultSettingsService: VaultSettingsService,

View File

@@ -0,0 +1,40 @@
<bit-section *ngIf="ciphers?.length">
<bit-section-header>
<h2 bitTypography="h6">
{{ headerText }}
</h2>
<span bitTypography="body1" slot="end">{{ ciphers.length }}</span>
</bit-section-header>
<bit-item-group>
<bit-item *ngFor="let cipher of ciphers">
<a
bit-item-content
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
(click)="onViewCipher(cipher)"
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
<span data-testid="item-name">{{ cipher.name }}</span>
</a>
<ng-container slot="end" *ngIf="cipher.edit">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
[title]="'moreOptionsTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<button type="button" bitMenuItem (click)="restore(cipher)">
{{ "restore" | i18n }}
</button>
<button type="button" bitMenuItem (click)="delete(cipher)">
{{ "deleteForever" | i18n }}
</button>
</bit-menu>
</bit-item-action>
</ng-container>
</bit-item>
</bit-item-group>
</bit-section>

View File

@@ -0,0 +1,107 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DialogService,
IconButtonModule,
ItemModule,
MenuModule,
SectionComponent,
SectionHeaderComponent,
ToastService,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@Component({
selector: "app-trash-list-items-container",
templateUrl: "trash-list-items-container.component.html",
standalone: true,
imports: [
CommonModule,
ItemModule,
JslibModule,
SectionComponent,
SectionHeaderComponent,
MenuModule,
IconButtonModule,
],
})
export class TrashListItemsContainerComponent {
/**
* The list of trashed items to display.
*/
@Input()
ciphers: CipherView[] = [];
@Input()
headerText: string;
constructor(
private cipherService: CipherService,
private logService: LogService,
private toastService: ToastService,
private i18nService: I18nService,
private dialogService: DialogService,
private passwordRepromptService: PasswordRepromptService,
private router: Router,
) {}
async restore(cipher: CipherView) {
try {
await this.cipherService.restoreWithServer(cipher.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
});
} catch (e) {
this.logService.error(e);
}
}
async delete(cipher: CipherView) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: { key: "permanentlyDeleteItemConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
await this.cipherService.deleteWithServer(cipher.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("deletedItem"),
});
} catch (e) {
this.logService.error(e);
}
}
async onViewCipher(cipher: CipherView) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
await this.router.navigate(["/view-cipher"], {
queryParams: { cipherId: cipher.id, type: cipher.type },
});
}
}

View File

@@ -0,0 +1,33 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'trash' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<ng-container *ngIf="deletedCiphers$ | async as deletedItems">
<bit-callout *ngIf="deletedItems.length" type="warning" title="{{ 'warning' | i18n }}">
{{ "trashWarning" | i18n }}
</bit-callout>
<ng-container *ngIf="deletedItems.length; else noDeletedItems">
<app-trash-list-items-container
[headerText]="'itemsInTrash' | i18n"
[ciphers]="deletedItems"
></app-trash-list-items-container>
</ng-container>
<ng-template #noDeletedItems>
<bit-no-items
[icon]="emptyTrashIcon"
class="tw-flex tw-h-full tw-items-center tw-justify-center"
>
<ng-container slot="title">
{{ "noItemsInTrash" | i18n }}
</ng-container>
<ng-container slot="description">
{{ "noItemsInTrashDesc" | i18n }}
</ng-container>
</bit-no-items>
</ng-template>
</ng-container>
</popup-page>

View File

@@ -0,0 +1,37 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CalloutModule, NoItemsModule } from "@bitwarden/components";
import { VaultIcons } from "@bitwarden/vault";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { VaultListItemsContainerComponent } from "../components/vault-v2/vault-list-items-container/vault-list-items-container.component";
import { VaultPopupItemsService } from "../services/vault-popup-items.service";
import { TrashListItemsContainerComponent } from "./trash-list-items-container/trash-list-items-container.component";
@Component({
templateUrl: "trash.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
VaultListItemsContainerComponent,
TrashListItemsContainerComponent,
CalloutModule,
NoItemsModule,
],
})
export class TrashComponent {
protected deletedCiphers$ = this.vaultPopupItemsService.deletedCiphers$;
protected emptyTrashIcon = VaultIcons.EmptyTrash;
constructor(private vaultPopupItemsService: VaultPopupItemsService) {}
}

View File

@@ -24,6 +24,12 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
<bit-item>
<a bit-item-content routerLink="/trash">
{{ "trash" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
<bit-item>
<button type="button" bit-item-content (click)="sync()">
{{ "syncVaultNow" | i18n }}