diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bc2448d924e..89b605ab632 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" } } diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index 227ede146ba..3da6fdef196 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -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), diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 455909336b3..82e673a9e54 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -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() diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 6840924fb0a..2595a6459c4 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -5,13 +5,30 @@ - - + + {{ "edit" | i18n }} + + + {{ "restore" | i18n }} + + => { + 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)); + } } diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 03e37fb71c9..77a86dd9e88 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -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); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index be5c9087315..3714d07241f 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -76,7 +76,7 @@ export class VaultPopupItemsService { * Observable that contains the list of all decrypted ciphers. * @private */ - private _cipherList$: Observable = merge( + private _allDecryptedCiphers$: Observable = 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 = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => combineLatest([ this.organizationService.organizations$, @@ -105,11 +109,10 @@ export class VaultPopupItemsService { }), ), ), - shareReplay({ refCount: true, bufferSize: 1 }), ); private _filteredCipherList$: Observable = 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 = this._cipherList$.pipe(map((ciphers) => !ciphers.length)); + emptyVault$: Observable = 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 = this._allDecryptedCiphers$.pipe( + map((ciphers) => ciphers.filter((c) => c.isDeleted)), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + constructor( private cipherService: CipherService, private vaultSettingsService: VaultSettingsService, diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html new file mode 100644 index 00000000000..2ccfeaf3459 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html @@ -0,0 +1,40 @@ + + + + {{ headerText }} + + {{ ciphers.length }} + + + + + + {{ cipher.name }} + + + + + + + {{ "restore" | i18n }} + + + {{ "deleteForever" | i18n }} + + + + + + + diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts new file mode 100644 index 00000000000..1ec0f52aa6d --- /dev/null +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -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 }, + }); + } +} diff --git a/apps/browser/src/vault/popup/settings/trash.component.html b/apps/browser/src/vault/popup/settings/trash.component.html new file mode 100644 index 00000000000..ab3b6716504 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/trash.component.html @@ -0,0 +1,33 @@ + + + + + + + + + {{ "trashWarning" | i18n }} + + + + + + + + + + {{ "noItemsInTrash" | i18n }} + + + {{ "noItemsInTrashDesc" | i18n }} + + + + + diff --git a/apps/browser/src/vault/popup/settings/trash.component.ts b/apps/browser/src/vault/popup/settings/trash.component.ts new file mode 100644 index 00000000000..b6f77ef6a52 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/trash.component.ts @@ -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) {} +} diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index 10243bdaa9f..03dd1182fbb 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -24,6 +24,12 @@ + + + {{ "trash" | i18n }} + + + {{ "syncVaultNow" | i18n }} diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index a675384ff9f..9b4bfdb5970 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -3,6 +3,15 @@ {{ "cardExpiredMessage" | i18n }} + + + {{ "noEditPermissions" | i18n }} + + + + + + + + + + + + + + + +`; diff --git a/libs/vault/src/icons/index.ts b/libs/vault/src/icons/index.ts index 3bb5c285fbe..c1b69a31ef5 100644 --- a/libs/vault/src/icons/index.ts +++ b/libs/vault/src/icons/index.ts @@ -1,3 +1,4 @@ export * from "./deactivated-org"; export * from "./no-folders"; export * from "./vault"; +export * from "./empty-trash";
+ {{ "noEditPermissions" | i18n }} +