diff --git a/jslib b/jslib index 72e3893f8e..3a10c1ff30 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit 72e3893f8eee79f1e3678839aa194f1096c343ea +Subproject commit 3a10c1ff3027832953094b2f7edddb2361119b09 diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7f3a0ed6df..1624fa7d84 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -145,6 +145,7 @@ import { AddEditComponent } from './vault/add-edit.component'; import { AttachmentsComponent } from './vault/attachments.component'; import { BulkDeleteComponent } from './vault/bulk-delete.component'; import { BulkMoveComponent } from './vault/bulk-move.component'; +import { BulkRestoreComponent } from './vault/bulk-restore.component'; import { BulkShareComponent } from './vault/bulk-share.component'; import { CiphersComponent } from './vault/ciphers.component'; import { CollectionsComponent } from './vault/collections.component'; @@ -257,6 +258,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); BreachReportComponent, BulkDeleteComponent, BulkMoveComponent, + BulkRestoreComponent, BulkShareComponent, CalloutComponent, ChangeEmailComponent, @@ -375,6 +377,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); AttachmentsComponent, BulkDeleteComponent, BulkMoveComponent, + BulkRestoreComponent, BulkShareComponent, CollectionsComponent, DeauthorizeSessionsComponent, diff --git a/src/app/organizations/vault/add-edit.component.ts b/src/app/organizations/vault/add-edit.component.ts index fe2c3c2c71..e19fd17629 100644 --- a/src/app/organizations/vault/add-edit.component.ts +++ b/src/app/organizations/vault/add-edit.component.ts @@ -94,6 +94,7 @@ export class AddEditComponent extends BaseAddEditComponent { if (!this.organization.isAdmin) { return super.deleteCipher(); } - return this.apiService.deleteCipherAdmin(this.cipherId); + return this.cipher.isDeleted ? this.apiService.deleteCipherAdmin(this.cipherId) + : this.apiService.putDeleteCipherAdmin(this.cipherId); } } diff --git a/src/app/organizations/vault/ciphers.component.ts b/src/app/organizations/vault/ciphers.component.ts index 63a3f9a7cf..ea990cd75a 100644 --- a/src/app/organizations/vault/ciphers.component.ts +++ b/src/app/organizations/vault/ciphers.component.ts @@ -41,7 +41,7 @@ export class CiphersComponent extends BaseCiphersComponent { async load(filter: (cipher: CipherView) => boolean = null) { if (!this.organization.isAdmin) { - await super.load(filter); + await super.load(filter, this.deleted); return; } this.accessEvents = this.organization.useEvents; @@ -65,13 +65,19 @@ export class CiphersComponent extends BaseCiphersComponent { } this.searchPending = false; let filteredCiphers = this.allCiphers; - if (this.filter != null) { - filteredCiphers = filteredCiphers.filter(this.filter); - } + if (this.searchText == null || this.searchText.trim().length < 2) { - this.ciphers = filteredCiphers; + this.ciphers = filteredCiphers.filter((c) => { + if (c.isDeleted !== this.deleted) { + return false; + } + return this.filter == null || this.filter(c); + }); } else { - this.ciphers = this.searchService.searchCiphersBasic(filteredCiphers, this.searchText); + if (this.filter != null) { + filteredCiphers = filteredCiphers.filter(this.filter); + } + this.ciphers = this.searchService.searchCiphersBasic(filteredCiphers, this.searchText, this.deleted); } await this.resetPaging(); } @@ -86,9 +92,9 @@ export class CiphersComponent extends BaseCiphersComponent { protected deleteCipher(id: string) { if (!this.organization.isAdmin) { - return super.deleteCipher(id); + return super.deleteCipher(id, this.deleted); } - return this.apiService.deleteCipherAdmin(id); + return this.deleted ? this.apiService.deleteCipherAdmin(id) : this.apiService.putDeleteCipherAdmin(id); } protected showFixOldAttachments(c: CipherView) { diff --git a/src/app/organizations/vault/vault.component.html b/src/app/organizations/vault/vault.component.html index 361f0a3efa..3f836dee51 100644 --- a/src/app/organizations/vault/vault.component.html +++ b/src/app/organizations/vault/vault.component.html @@ -1,9 +1,10 @@
- + (onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)" + (onTrashClicked)="filterDeleted()">
@@ -18,7 +19,8 @@ -
@@ -33,4 +35,4 @@ - \ No newline at end of file + diff --git a/src/app/organizations/vault/vault.component.ts b/src/app/organizations/vault/vault.component.ts index 87b300144d..b42bcbf60f 100644 --- a/src/app/organizations/vault/vault.component.ts +++ b/src/app/organizations/vault/vault.component.ts @@ -49,8 +49,9 @@ export class VaultComponent implements OnInit, OnDestroy { @ViewChild('eventsTemplate', { read: ViewContainerRef }) eventsModalRef: ViewContainerRef; organization: Organization; - collectionId: string; - type: CipherType; + collectionId: string = null; + type: CipherType = null; + deleted: boolean = false; private modal: ModalComponent = null; @@ -61,7 +62,7 @@ export class VaultComponent implements OnInit, OnDestroy { private broadcasterService: BroadcasterService, private ngZone: NgZone) { } ngOnInit() { - this.route.parent.params.subscribe(async (params) => { + const queryParams = this.route.parent.params.subscribe(async (params) => { this.organization = await this.userService.getOrganization(params.organizationId); this.groupingsComponent.organization = this.organization; this.ciphersComponent.organization = this.organization; @@ -92,7 +93,10 @@ export class VaultComponent implements OnInit, OnDestroy { this.groupingsComponent.selectedAll = true; await this.ciphersComponent.reload(); } else { - if (qParams.type) { + if (qParams.deleted) { + this.groupingsComponent.selectedTrash = true; + await this.filterDeleted(true); + } else if (qParams.type) { const t = parseInt(qParams.type, null); this.groupingsComponent.selectedType = t; await this.filterCipherType(t, true); @@ -116,6 +120,10 @@ export class VaultComponent implements OnInit, OnDestroy { queryParamsSub.unsubscribe(); } }); + + if (queryParams != null) { + queryParams.unsubscribe(); + } }); } @@ -125,6 +133,7 @@ export class VaultComponent implements OnInit, OnDestroy { async clearGroupingFilters() { this.ciphersComponent.showAddNew = true; + this.ciphersComponent.deleted = false; this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchVault'); await this.ciphersComponent.applyFilter(); this.clearFilters(); @@ -133,6 +142,7 @@ export class VaultComponent implements OnInit, OnDestroy { async filterCipherType(type: CipherType, load = false) { this.ciphersComponent.showAddNew = true; + this.ciphersComponent.deleted = false; this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType'); const filter = (c: CipherView) => c.type === type; if (load) { @@ -147,6 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy { async filterCollection(collectionId: string, load = false) { this.ciphersComponent.showAddNew = true; + this.ciphersComponent.deleted = false; this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchCollection'); const filter = (c: CipherView) => { if (collectionId === 'unassigned') { @@ -165,6 +176,20 @@ export class VaultComponent implements OnInit, OnDestroy { this.go(); } + async filterDeleted(load: boolean = false) { + this.ciphersComponent.showAddNew = false; + this.ciphersComponent.deleted = true; + this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchTrash'); + if (load) { + await this.ciphersComponent.reload(null, true); + } else { + await this.ciphersComponent.applyFilter(null); + } + this.clearFilters(); + this.deleted = true; + this.go(); + } + filterSearchText(searchText: string) { this.ciphersComponent.searchText = searchText; this.ciphersComponent.search(200); @@ -255,6 +280,10 @@ export class VaultComponent implements OnInit, OnDestroy { this.modal.close(); await this.ciphersComponent.refresh(); }); + childComponent.onRestoredCipher.subscribe(async (c: CipherView) => { + this.modal.close(); + await this.ciphersComponent.refresh(); + }); this.modal.onClosed.subscribe(() => { this.modal = null; @@ -299,6 +328,7 @@ export class VaultComponent implements OnInit, OnDestroy { private clearFilters() { this.collectionId = null; this.type = null; + this.deleted = false; } private go(queryParams: any = null) { @@ -306,6 +336,7 @@ export class VaultComponent implements OnInit, OnDestroy { queryParams = { type: this.type, collectionId: this.collectionId, + deleted: this.deleted ? true : null, }; } diff --git a/src/app/services/event.service.ts b/src/app/services/event.service.ts index e5707ea32f..d4912f4e73 100644 --- a/src/app/services/event.service.ts +++ b/src/app/services/event.service.ts @@ -73,8 +73,14 @@ export class EventService { msg = this.i18nService.t('editedItemId', this.formatCipherId(ev, options)); break; case EventType.Cipher_Deleted: + msg = this.i18nService.t('permanentlyDeletedItemId', this.formatCipherId(ev, options)); + break; + case EventType.Cipher_SoftDeleted: msg = this.i18nService.t('deletedItemId', this.formatCipherId(ev, options)); break; + case EventType.Cipher_Restored: + msg = this.i18nService.t('restoredItemId', this.formatCipherId(ev, options)); + break; case EventType.Cipher_AttachmentCreated: msg = this.i18nService.t('createdAttachmentForItem', this.formatCipherId(ev, options)); break; diff --git a/src/app/tools/cipher-report.component.ts b/src/app/tools/cipher-report.component.ts index ee331594c5..9b1fd8bbc2 100644 --- a/src/app/tools/cipher-report.component.ts +++ b/src/app/tools/cipher-report.component.ts @@ -62,6 +62,10 @@ export class CipherReportComponent { this.modal.close(); await this.load(); }); + childComponent.onRestoredCipher.subscribe(async (c: CipherView) => { + this.modal.close(); + await this.load(); + }); this.modal.onClosed.subscribe(() => { this.modal = null; diff --git a/src/app/vault/add-edit.component.html b/src/app/vault/add-edit.component.html index 6794974b14..01eb93e10b 100644 --- a/src/app/vault/add-edit.component.html +++ b/src/app/vault/add-edit.component.html @@ -12,7 +12,8 @@
-
@@ -21,11 +22,12 @@
+ required [disabled]="cipher.isDeleted">
-
@@ -37,8 +39,8 @@
-
+ [(ngModel)]="cipher.login.username" appInputVerbatim [disabled]="cipher.isDeleted"> +
-
+
{{'uriPosition' | i18n : (i + 1)}}
-
+
-
+ {{'newUri' | i18n}} @@ -182,12 +186,13 @@
+ name="Card.CardCardholderName" [(ngModel)]="cipher.card.cardholderName" + [disabled]="cipher.isDeleted">
@@ -197,8 +202,8 @@
-
+ [(ngModel)]="cipher.card.number" appInputVerbatim [disabled]="cipher.isDeleted"> +
@@ -226,8 +232,9 @@
-
+ [(ngModel)]="cipher.card.code" appInputVerbatim autocomplete="new-password" + [disabled]="cipher.isDeleted"> +
+ [(ngModel)]="cipher.identity.username" appInputVerbatim [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.company" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.ssn" appInputVerbatim [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.passportNumber" appInputVerbatim [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.licenseNumber" appInputVerbatim [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.email" appInputVerbatim [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.phone" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.address1" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.address2" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.address3" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.city" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.state" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.postalCode" [disabled]="cipher.isDeleted">
+ [(ngModel)]="cipher.identity.country" [disabled]="cipher.isDeleted">
-

{{'customFields' | i18n}}

@@ -374,15 +381,15 @@
+ class="form-control" appInputVerbatim [disabled]="cipher.isDeleted">
-
+ [(ngModel)]="f.value" appInputVerbatim [disabled]="cipher.isDeleted"> +
- + {{'newCustomField' | i18n}} -
+
+ [(ngModel)]="cipher.organizationId" (change)="organizationChanged()" + [disabled]="cipher.isDeleted">
@@ -459,7 +467,7 @@
+ id="collection-{{i}}" name="Collection[{{i}}].Checked" [disabled]="cipher.isDeleted">
@@ -492,19 +500,20 @@ \ No newline at end of file +
diff --git a/src/app/vault/bulk-delete.component.html b/src/app/vault/bulk-delete.component.html index d1ba62021a..4ca7ff1637 100644 --- a/src/app/vault/bulk-delete.component.html +++ b/src/app/vault/bulk-delete.component.html @@ -3,19 +3,19 @@ @@ -93,4 +99,4 @@ {{'addItem' | i18n}}
- \ No newline at end of file + diff --git a/src/app/vault/ciphers.component.ts b/src/app/vault/ciphers.component.ts index 3cb4346846..a13406690d 100644 --- a/src/app/vault/ciphers.component.ts +++ b/src/app/vault/ciphers.component.ts @@ -100,18 +100,43 @@ export class CiphersComponent extends BaseCiphersComponent implements OnDestroy if (this.actionPromise != null) { return; } + const permanent = c.isDeleted; const confirmed = await this.platformUtilsService.showDialog( - this.i18nService.t('deleteItemConfirmation'), this.i18nService.t('deleteItem'), + this.i18nService.t(permanent ? 'permanentlyDeleteItemConfirmation' : 'deleteItemConfirmation'), + this.i18nService.t(permanent ? 'permanentlyDeleteItem' : 'deleteItem'), this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); if (!confirmed) { return false; } try { - this.actionPromise = this.deleteCipher(c.id); + this.actionPromise = this.deleteCipher(c.id, permanent); await this.actionPromise; this.analytics.eventTrack.next({ action: 'Deleted Cipher' }); - this.toasterService.popAsync('success', null, this.i18nService.t('deletedItem')); + this.toasterService.popAsync('success', null, this.i18nService.t(permanent ? 'permanentlyDeletedItem' + : 'deletedItem')); + this.refresh(); + } catch { } + this.actionPromise = null; + } + + async restore(c: CipherView): Promise { + if (this.actionPromise != null || !c.isDeleted) { + return; + } + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t('restoreItemConfirmation'), + this.i18nService.t('restoreItem'), + this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); + if (!confirmed) { + return false; + } + + try { + this.actionPromise = this.cipherService.restoreWithServer(c.id); + await this.actionPromise; + this.analytics.eventTrack.next({ action: 'Restored Cipher' }); + this.toasterService.popAsync('success', null, this.i18nService.t('restoredItem')); this.refresh(); } catch { } this.actionPromise = null; @@ -134,8 +159,8 @@ export class CiphersComponent extends BaseCiphersComponent implements OnDestroy } } - protected deleteCipher(id: string) { - return this.cipherService.deleteWithServer(id); + protected deleteCipher(id: string, permanent: boolean) { + return permanent ? this.cipherService.deleteWithServer(id) : this.cipherService.softDeleteWithServer(id); } protected showFixOldAttachments(c: CipherView) { diff --git a/src/app/vault/groupings.component.html b/src/app/vault/groupings.component.html index 21685b2f34..01557608f3 100644 --- a/src/app/vault/groupings.component.html +++ b/src/app/vault/groupings.component.html @@ -20,6 +20,11 @@ {{'favorites' | i18n}} +
  • + + {{'trash' | i18n}} + +
  • {{'types' | i18n}}

      diff --git a/src/app/vault/vault.component.html b/src/app/vault/vault.component.html index f66e327cca..3b5ace702e 100644 --- a/src/app/vault/vault.component.html +++ b/src/app/vault/vault.component.html @@ -4,7 +4,8 @@ + (onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)" + (onTrashClicked)="filterDeleted()">
    -
    @@ -118,6 +123,7 @@ + - \ No newline at end of file + diff --git a/src/app/vault/vault.component.ts b/src/app/vault/vault.component.ts index b4cbf13fb2..ee3705732d 100644 --- a/src/app/vault/vault.component.ts +++ b/src/app/vault/vault.component.ts @@ -27,6 +27,7 @@ import { AddEditComponent } from './add-edit.component'; import { AttachmentsComponent } from './attachments.component'; import { BulkDeleteComponent } from './bulk-delete.component'; import { BulkMoveComponent } from './bulk-move.component'; +import { BulkRestoreComponent } from './bulk-restore.component'; import { BulkShareComponent } from './bulk-share.component'; import { CiphersComponent } from './ciphers.component'; import { CollectionsComponent } from './collections.component'; @@ -60,6 +61,7 @@ export class VaultComponent implements OnInit, OnDestroy { @ViewChild('share', { read: ViewContainerRef }) shareModalRef: ViewContainerRef; @ViewChild('collections', { read: ViewContainerRef }) collectionsModalRef: ViewContainerRef; @ViewChild('bulkDeleteTemplate', { read: ViewContainerRef }) bulkDeleteModalRef: ViewContainerRef; + @ViewChild('bulkRestoreTemplate', { read: ViewContainerRef }) bulkRestoreModalRef: ViewContainerRef; @ViewChild('bulkMoveTemplate', { read: ViewContainerRef }) bulkMoveModalRef: ViewContainerRef; @ViewChild('bulkShareTemplate', { read: ViewContainerRef }) bulkShareModalRef: ViewContainerRef; @ViewChild('updateKeyTemplate', { read: ViewContainerRef }) updateKeyModalRef: ViewContainerRef; @@ -72,6 +74,7 @@ export class VaultComponent implements OnInit, OnDestroy { showBrowserOutdated = false; showUpdateKey = false; showPremiumCallout = false; + deleted: boolean = false; private modal: ModalComponent = null; @@ -104,7 +107,10 @@ export class VaultComponent implements OnInit, OnDestroy { this.groupingsComponent.selectedAll = true; await this.ciphersComponent.reload(); } else { - if (params.favorites) { + if (params.deleted) { + this.groupingsComponent.selectedTrash = true; + await this.filterDeleted(); + } else if (params.favorites) { this.groupingsComponent.selectedFavorites = true; await this.filterFavorites(); } else if (params.type) { @@ -168,6 +174,16 @@ export class VaultComponent implements OnInit, OnDestroy { this.go(); } + async filterDeleted() { + this.ciphersComponent.showAddNew = false; + this.ciphersComponent.deleted = true; + this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchTrash'); + await this.ciphersComponent.reload(null, true); + this.clearFilters(); + this.deleted = true; + this.go(); + } + async filterCipherType(type: CipherType) { this.ciphersComponent.showAddNew = true; this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType'); @@ -358,6 +374,10 @@ export class VaultComponent implements OnInit, OnDestroy { this.modal.close(); await this.ciphersComponent.refresh(); }); + childComponent.onRestoredCipher.subscribe(async (c: CipherView) => { + this.modal.close(); + await this.ciphersComponent.refresh(); + }); this.modal.onClosed.subscribe(() => { this.modal = null; @@ -387,6 +407,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.modal = this.bulkDeleteModalRef.createComponent(factory).instance; const childComponent = this.modal.show(BulkDeleteComponent, this.bulkDeleteModalRef); + childComponent.permanent = this.deleted; childComponent.cipherIds = selectedIds; childComponent.onDeleted.subscribe(async () => { this.modal.close(); @@ -398,6 +419,33 @@ export class VaultComponent implements OnInit, OnDestroy { }); } + bulkRestore() { + const selectedIds = this.ciphersComponent.getSelectedIds(); + if (selectedIds.length === 0) { + this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('nothingSelected')); + return; + } + + if (this.modal != null) { + this.modal.close(); + } + + const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent); + this.modal = this.bulkRestoreModalRef.createComponent(factory).instance; + const childComponent = this.modal.show(BulkRestoreComponent, this.bulkRestoreModalRef); + + childComponent.cipherIds = selectedIds; + childComponent.onRestored.subscribe(async () => { + this.modal.close(); + await this.ciphersComponent.refresh(); + }); + + this.modal.onClosed.subscribe(() => { + this.modal = null; + }); + } + bulkShare() { const selectedCiphers = this.ciphersComponent.getSelected(); if (selectedCiphers.length === 0) { @@ -475,6 +523,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.collectionId = null; this.favorites = false; this.type = null; + this.deleted = false; } private go(queryParams: any = null) { @@ -484,6 +533,7 @@ export class VaultComponent implements OnInit, OnDestroy { type: this.type, folderId: this.folderId, collectionId: this.collectionId, + deleted: this.deleted ? true : null, }; } diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index c3251097d3..0c8624ca85 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -3053,5 +3053,87 @@ "lock": { "message": "Lock", "description": "Verb form: to make secure or inaccesible by" + }, + "trash": { + "message": "Trash", + "description": "Noun: A special folder for holding deleted items that have not yet been permanently deleted" + }, + "searchTrash": { + "message": "Search Trash" + }, + "permanentlyDelete": { + "message": "Permanently Delete" + }, + "permanentlyDeleteSelected": { + "message": "Permanently Delete Selected" + }, + "permanentlyDeleteItem": { + "message": "Permanently Delete Item" + }, + "permanentlyDeleteItemConfirmation": { + "message": "Are you sure you want to permanently delete this item?" + }, + "permanentlyDeletedItem": { + "message": "Permanently Deleted item" + }, + "permanentlyDeletedItems": { + "message": "Permanently Deleted items" + }, + "permanentlyDeleteSelectedItemsDesc": { + "message": "You have selected $COUNT$ item(s) to permanently delete. Are you sure you want to permanently delete all of these items?", + "placeholders": { + "count": { + "content": "$1", + "example": "150" + } + } + }, + "permanentlyDeletedItemId": { + "message": "Permanently Deleted item $ID$.", + "placeholders": { + "id": { + "content": "$1", + "example": "Google" + } + } + }, + "restore": { + "message": "Restore" + }, + "restoreSelected": { + "message": "Restore Selected" + }, + "restoreItem": { + "message": "Restore Item" + }, + "restoredItem": { + "message": "Restored Item" + }, + "restoredItems": { + "message": "Restored Items" + }, + "restoreItemConfirmation": { + "message": "Are you sure you want to restore this item?" + }, + "restoreItems": { + "message": "Restore items" + }, + "restoreSelectedItemsDesc": { + "message": "You have selected $COUNT$ item(s) to restore. Are you sure you want to restore all of these items?", + "placeholders": { + "count": { + "content": "$1", + "example": "150" + } + } + }, + "restoredItemId": { + "message": "Restored item $ID$.", + "placeholders": { + "id": { + "content": "$1", + "example": "Google" + } + } } }