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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
33
apps/browser/src/vault/popup/settings/trash.component.html
Normal file
33
apps/browser/src/vault/popup/settings/trash.component.html
Normal 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>
|
||||
37
apps/browser/src/vault/popup/settings/trash.component.ts
Normal file
37
apps/browser/src/vault/popup/settings/trash.component.ts
Normal 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) {}
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user