From 19668ab5f2a979cc52c3c3d18033b6899eecb8a5 Mon Sep 17 00:00:00 2001 From: Chad Scharf <3904944+cscharf@users.noreply.github.com> Date: Fri, 3 Apr 2020 16:32:15 -0400 Subject: [PATCH 1/3] [Soft Delete] jslib updates for new API updates New API methods and cipher Deleted Date property, plus search expansion to toggle on deleted flag. --- src/abstractions/api.service.ts | 7 ++ src/abstractions/cipher.service.ts | 6 ++ src/abstractions/search.service.ts | 4 +- src/angular/components/ciphers.component.ts | 8 ++- src/angular/components/groupings.component.ts | 10 +++ src/angular/pipes/search-ciphers.pipe.ts | 9 ++- src/enums/eventType.ts | 2 + src/models/data/cipherData.ts | 2 + src/models/domain/cipher.ts | 3 + .../request/cipherBulkRestoreRequest.ts | 7 ++ src/models/response/cipherResponse.ts | 2 + src/models/view/cipherView.ts | 6 ++ src/services/api.service.ts | 24 +++++++ src/services/cipher.service.ts | 71 +++++++++++++++++++ src/services/search.service.ts | 21 ++++-- 15 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 src/models/request/cipherBulkRestoreRequest.ts diff --git a/src/abstractions/api.service.ts b/src/abstractions/api.service.ts index ac2d913b39d..1fc71b9c1cf 100644 --- a/src/abstractions/api.service.ts +++ b/src/abstractions/api.service.ts @@ -5,6 +5,7 @@ import { EnvironmentUrls } from '../models/domain/environmentUrls'; import { BitPayInvoiceRequest } from '../models/request/bitPayInvoiceRequest'; import { CipherBulkDeleteRequest } from '../models/request/cipherBulkDeleteRequest'; import { CipherBulkMoveRequest } from '../models/request/cipherBulkMoveRequest'; +import { CipherBulkRestoreRequest } from '../models/request/cipherBulkRestoreRequest'; import { CipherBulkShareRequest } from '../models/request/cipherBulkShareRequest'; import { CipherCollectionsRequest } from '../models/request/cipherCollectionsRequest'; import { CipherCreateRequest } from '../models/request/cipherCreateRequest'; @@ -163,6 +164,12 @@ export abstract class ApiService { postPurgeCiphers: (request: PasswordVerificationRequest, organizationId?: string) => Promise; postImportCiphers: (request: ImportCiphersRequest) => Promise; postImportOrganizationCiphers: (organizationId: string, request: ImportOrganizationCiphersRequest) => Promise; + putDeleteCipher: (id: string) => Promise; + putDeleteCipherAdmin: (id: string) => Promise; + putDeleteManyCiphers: (request: CipherBulkDeleteRequest) => Promise; + putRestoreCipher: (id: string) => Promise; + putRestoreCipherAdmin: (id: string) => Promise; + putRestoreManyCiphers: (request: CipherBulkRestoreRequest) => Promise; postCipherAttachment: (id: string, data: FormData) => Promise; postCipherAttachmentAdmin: (id: string, data: FormData) => Promise; diff --git a/src/abstractions/cipher.service.ts b/src/abstractions/cipher.service.ts index 97c9c37abcf..3c55b2cdbf8 100644 --- a/src/abstractions/cipher.service.ts +++ b/src/abstractions/cipher.service.ts @@ -45,4 +45,10 @@ export abstract class CipherService { sortCiphersByLastUsed: (a: any, b: any) => number; sortCiphersByLastUsedThenName: (a: any, b: any) => number; getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number; + softDelete: (id: string | string[]) => Promise; + softDeleteWithServer: (id: string) => Promise; + softDeleteManyWithServer: (ids: string[]) => Promise; + restore: (id: string | string[]) => Promise; + restoreWithServer: (id: string) => Promise; + restoreManyWithServer: (ids: string[]) => Promise; } diff --git a/src/abstractions/search.service.ts b/src/abstractions/search.service.ts index 1ef8f92cd00..3e146e94bcb 100644 --- a/src/abstractions/search.service.ts +++ b/src/abstractions/search.service.ts @@ -5,6 +5,6 @@ export abstract class SearchService { isSearchable: (query: string) => boolean; indexCiphers: () => Promise; searchCiphers: (query: string, filter?: (cipher: CipherView) => boolean, - ciphers?: CipherView[]) => Promise; - searchCiphersBasic: (ciphers: CipherView[], query: string) => CipherView[]; + ciphers?: CipherView[], deleted?: boolean) => Promise; + searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[]; } diff --git a/src/angular/components/ciphers.component.ts b/src/angular/components/ciphers.component.ts index b7220f514c6..d00bd1ca73d 100644 --- a/src/angular/components/ciphers.component.ts +++ b/src/angular/components/ciphers.component.ts @@ -21,6 +21,7 @@ export class CiphersComponent { searchText: string; searchPlaceholder: string = null; filter: (cipher: CipherView) => boolean = null; + deleted: boolean = false; protected searchPending = false; protected didScroll = false; @@ -32,7 +33,8 @@ export class CiphersComponent { constructor(protected searchService: SearchService) { } - async load(filter: (cipher: CipherView) => boolean = null) { + async load(filter: (cipher: CipherView) => boolean = null, deleted: boolean = false) { + this.deleted = deleted || false; await this.applyFilter(filter); this.loaded = true; } @@ -79,13 +81,13 @@ export class CiphersComponent { clearTimeout(this.searchTimeout); } if (timeout == null) { - this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter); + this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter, null, this.deleted); await this.resetPaging(); return; } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter); + this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter, null, this.deleted); await this.resetPaging(); this.searchPending = false; }, timeout); diff --git a/src/angular/components/groupings.component.ts b/src/angular/components/groupings.component.ts index 9983823dfa8..022da692d51 100644 --- a/src/angular/components/groupings.component.ts +++ b/src/angular/components/groupings.component.ts @@ -22,9 +22,11 @@ export class GroupingsComponent { @Input() showFolders = true; @Input() showCollections = true; @Input() showFavorites = true; + @Input() showTrash = true; @Output() onAllClicked = new EventEmitter(); @Output() onFavoritesClicked = new EventEmitter(); + @Output() onTrashClicked = new EventEmitter(); @Output() onCipherTypeClicked = new EventEmitter(); @Output() onFolderClicked = new EventEmitter(); @Output() onAddFolder = new EventEmitter(); @@ -39,6 +41,7 @@ export class GroupingsComponent { cipherType = CipherType; selectedAll: boolean = false; selectedFavorites: boolean = false; + selectedTrash: boolean = false; selectedType: CipherType = null; selectedFolder: boolean = false; selectedFolderId: string = null; @@ -101,6 +104,12 @@ export class GroupingsComponent { this.onFavoritesClicked.emit(); } + selectTrash() { + this.clearSelections(); + this.selectedTrash = true; + this.onTrashClicked.emit(); + } + selectType(type: CipherType) { this.clearSelections(); this.selectedType = type; @@ -131,6 +140,7 @@ export class GroupingsComponent { clearSelections() { this.selectedAll = false; this.selectedFavorites = false; + this.selectedTrash = false; this.selectedType = null; this.selectedFolder = false; this.selectedFolderId = null; diff --git a/src/angular/pipes/search-ciphers.pipe.ts b/src/angular/pipes/search-ciphers.pipe.ts index e81371c4e89..1de1fbf267a 100644 --- a/src/angular/pipes/search-ciphers.pipe.ts +++ b/src/angular/pipes/search-ciphers.pipe.ts @@ -19,17 +19,22 @@ export class SearchCiphersPipe implements PipeTransform { this.onlySearchName = platformUtilsService.getDevice() === DeviceType.EdgeExtension; } - transform(ciphers: CipherView[], searchText: string): CipherView[] { + transform(ciphers: CipherView[], searchText: string, deleted: boolean = false): CipherView[] { if (ciphers == null || ciphers.length === 0) { return []; } if (searchText == null || searchText.length < 2) { - return ciphers; + return ciphers.filter((c) => { + return deleted !== c.isDeleted; + }); } searchText = searchText.trim().toLowerCase(); return ciphers.filter((c) => { + if (deleted !== c.isDeleted) { + return false; + } if (c.name != null && c.name.toLowerCase().indexOf(searchText) > -1) { return true; } diff --git a/src/enums/eventType.ts b/src/enums/eventType.ts index eed006e9157..40f626e7514 100644 --- a/src/enums/eventType.ts +++ b/src/enums/eventType.ts @@ -23,6 +23,8 @@ export enum EventType { Cipher_ClientCopiedHiddenField = 1112, Cipher_ClientCopiedCardCode = 1113, Cipher_ClientAutofilled = 1114, + Cipher_SoftDeleted = 1115, + Cipher_Restored = 1116, Collection_Created = 1300, Collection_Updated = 1301, diff --git a/src/models/data/cipherData.ts b/src/models/data/cipherData.ts index 19478debada..9a45c73daeb 100644 --- a/src/models/data/cipherData.ts +++ b/src/models/data/cipherData.ts @@ -31,6 +31,7 @@ export class CipherData { attachments?: AttachmentData[]; passwordHistory?: PasswordHistoryData[]; collectionIds?: string[]; + deletedDate: string; constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) { if (response == null) { @@ -49,6 +50,7 @@ export class CipherData { this.name = response.name; this.notes = response.notes; this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds; + this.deletedDate = response.deletedDate; switch (this.type) { case CipherType.Login: diff --git a/src/models/domain/cipher.ts b/src/models/domain/cipher.ts index 85a961dd8f3..647251f4ea9 100644 --- a/src/models/domain/cipher.ts +++ b/src/models/domain/cipher.ts @@ -34,6 +34,7 @@ export class Cipher extends Domain { fields: Field[]; passwordHistory: Password[]; collectionIds: string[]; + deletedDate: Date; constructor(obj?: CipherData, alreadyEncrypted: boolean = false, localData: any = null) { super(); @@ -57,6 +58,7 @@ export class Cipher extends Domain { this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; this.collectionIds = obj.collectionIds; this.localData = localData; + this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : null; switch (this.type) { case CipherType.Login: @@ -172,6 +174,7 @@ export class Cipher extends Domain { c.revisionDate = this.revisionDate != null ? this.revisionDate.toISOString() : null; c.type = this.type; c.collectionIds = this.collectionIds; + c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null; this.buildDataModel(this, c, { name: null, diff --git a/src/models/request/cipherBulkRestoreRequest.ts b/src/models/request/cipherBulkRestoreRequest.ts new file mode 100644 index 00000000000..546cc92450e --- /dev/null +++ b/src/models/request/cipherBulkRestoreRequest.ts @@ -0,0 +1,7 @@ +export class CipherBulkRestoreRequest { + ids: string[]; + + constructor(ids: string[]) { + this.ids = ids == null ? [] : ids; + } +} diff --git a/src/models/response/cipherResponse.ts b/src/models/response/cipherResponse.ts index 54584123d46..580e21fa7e3 100644 --- a/src/models/response/cipherResponse.ts +++ b/src/models/response/cipherResponse.ts @@ -27,6 +27,7 @@ export class CipherResponse extends BaseResponse { attachments: AttachmentResponse[]; passwordHistory: PasswordHistoryResponse[]; collectionIds: string[]; + deletedDate: string; constructor(response: any) { super(response); @@ -41,6 +42,7 @@ export class CipherResponse extends BaseResponse { this.organizationUseTotp = this.getResponseProperty('OrganizationUseTotp'); this.revisionDate = this.getResponseProperty('RevisionDate'); this.collectionIds = this.getResponseProperty('CollectionIds'); + this.deletedDate = this.getResponseProperty('DeletedDate'); const login = this.getResponseProperty('Login'); if (login != null) { diff --git a/src/models/view/cipherView.ts b/src/models/view/cipherView.ts index 14feea8c426..e1c8d5fa660 100644 --- a/src/models/view/cipherView.ts +++ b/src/models/view/cipherView.ts @@ -31,6 +31,7 @@ export class CipherView implements View { passwordHistory: PasswordHistoryView[] = null; collectionIds: string[] = null; revisionDate: Date = null; + deletedDate: Date = null; constructor(c?: Cipher) { if (!c) { @@ -47,6 +48,7 @@ export class CipherView implements View { this.localData = c.localData; this.collectionIds = c.collectionIds; this.revisionDate = c.revisionDate; + this.deletedDate = c.deletedDate; } get subTitle(): string { @@ -97,4 +99,8 @@ export class CipherView implements View { } return this.login.passwordRevisionDate; } + + get isDeleted(): boolean { + return this.deletedDate != null; + } } diff --git a/src/services/api.service.ts b/src/services/api.service.ts index b57ea4b8240..70a91f36c3f 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -434,6 +434,30 @@ export class ApiService implements ApiServiceAbstraction { return this.send('POST', '/ciphers/import-organization?organizationId=' + organizationId, request, true, false); } + putDeleteCipher(id: string): Promise { + return this.send('PUT', '/ciphers/' + id + '/delete', null, true, false); + } + + putDeleteCipherAdmin(id: string): Promise { + return this.send('PUT', '/ciphers/' + id + '/delete-admin', null, true, false); + } + + putDeleteManyCiphers(request: CipherBulkDeleteRequest): Promise { + return this.send('PUT', '/ciphers/delete', request, true, false); + } + + putRestoreCipher(id: string): Promise { + return this.send('PUT', '/ciphers/' + id + '/restore', null, true, false); + } + + putRestoreCipherAdmin(id: string): Promise { + return this.send('PUT', '/ciphers/' + id + '/restore-admin', null, true, false); + } + + putRestoreManyCiphers(request: CipherBulkDeleteRequest): Promise { + return this.send('PUT', '/ciphers/restore', request, true, false); + } + // Attachments APIs async postCipherAttachment(id: string, data: FormData): Promise { diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index 6e7296e09cf..d2856d7449b 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -19,6 +19,7 @@ import { SymmetricCryptoKey } from '../models/domain/symmetricCryptoKey'; import { CipherBulkDeleteRequest } from '../models/request/cipherBulkDeleteRequest'; import { CipherBulkMoveRequest } from '../models/request/cipherBulkMoveRequest'; +import { CipherBulkRestoreRequest } from '../models/request/cipherBulkRestoreRequest'; import { CipherBulkShareRequest } from '../models/request/cipherBulkShareRequest'; import { CipherCollectionsRequest } from '../models/request/cipherCollectionsRequest'; import { CipherCreateRequest } from '../models/request/cipherCreateRequest'; @@ -790,6 +791,76 @@ export class CipherService implements CipherServiceAbstraction { }; } + async softDelete(id: string | string[]): Promise { + const userId = await this.userService.getUserId(); + const ciphers = await this.storageService.get<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + if (ciphers == null) { + return; + } + + const setDeletedDate = (cipherId: string) => { + if (ciphers[cipherId] == null) { + return; + } + ciphers[cipherId].deletedDate = new Date().toISOString(); + }; + + if (typeof id === 'string') { + setDeletedDate(id); + } else { + (id as string[]).forEach(setDeletedDate); + } + + await this.storageService.save(Keys.ciphersPrefix + userId, ciphers); + this.decryptedCipherCache = null; + } + + async softDeleteWithServer(id: string): Promise { + await this.apiService.putDeleteCipher(id); + await this.softDelete(id); + } + + async softDeleteManyWithServer(ids: string[]): Promise { + await this.apiService.putDeleteManyCiphers(new CipherBulkDeleteRequest(ids)); + await this.softDelete(ids); + } + + async restore(id: string | string[]): Promise { + const userId = await this.userService.getUserId(); + const ciphers = await this.storageService.get<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + if (ciphers == null) { + return; + } + + const clearDeletedDate = (cipherId: string) => { + if (ciphers[cipherId] == null) { + return; + } + ciphers[cipherId].deletedDate = null; + }; + + if (typeof id === 'string') { + clearDeletedDate(id); + } else { + (id as string[]).forEach(clearDeletedDate); + } + + await this.storageService.save(Keys.ciphersPrefix + userId, ciphers); + this.decryptedCipherCache = null; + } + + async restoreWithServer(id: string): Promise { + await this.apiService.putRestoreCipher(id); + await this.restore(id); + } + + async restoreManyWithServer(ids: string[]): Promise { + await this.apiService.putRestoreManyCiphers(new CipherBulkRestoreRequest(ids)); + await this.restore(ids); + } + // Helpers private async shareAttachmentWithServer(attachmentView: AttachmentView, cipherId: string, diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 1977924d5ca..ff1f2bf994d 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -71,7 +71,8 @@ export class SearchService implements SearchServiceAbstraction { console.timeEnd('search indexing'); } - async searchCiphers(query: string, filter: (cipher: CipherView) => boolean = null, ciphers: CipherView[] = null): + async searchCiphers(query: string, filter: (cipher: CipherView) => boolean = null, ciphers: CipherView[] = null, + deleted: boolean = false): Promise { const results: CipherView[] = []; if (query != null) { @@ -84,9 +85,16 @@ export class SearchService implements SearchServiceAbstraction { if (ciphers == null) { ciphers = await this.cipherService.getAllDecrypted(); } - if (filter != null) { - ciphers = ciphers.filter(filter); - } + + ciphers = ciphers.filter((c) => { + if (deleted !== c.isDeleted) { + return false; + } + if (filter != null) { + return filter(c); + } + return true; + }); if (!this.isSearchable(query)) { return ciphers; @@ -138,9 +146,12 @@ export class SearchService implements SearchServiceAbstraction { return results; } - searchCiphersBasic(ciphers: CipherView[], query: string) { + searchCiphersBasic(ciphers: CipherView[], query: string, deleted: boolean = false) { query = query.trim().toLowerCase(); return ciphers.filter((c) => { + if (deleted !== c.isDeleted) { + return false; + } if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { return true; } From 2a3e03c70d7b4c6a9cc353af60bbd7c8936367f3 Mon Sep 17 00:00:00 2001 From: Chad Scharf <3904944+cscharf@users.noreply.github.com> Date: Mon, 6 Apr 2020 18:20:39 -0400 Subject: [PATCH 2/3] [Soft Delete] - Included deleted flag on reload --- src/angular/components/ciphers.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/angular/components/ciphers.component.ts b/src/angular/components/ciphers.component.ts index d00bd1ca73d..7244ca6e215 100644 --- a/src/angular/components/ciphers.component.ts +++ b/src/angular/components/ciphers.component.ts @@ -55,10 +55,10 @@ export class CiphersComponent { this.didScroll = this.pagedCiphers.length > this.pageSize; } - async reload(filter: (cipher: CipherView) => boolean = null) { + async reload(filter: (cipher: CipherView) => boolean = null, deleted: boolean = false) { this.loaded = false; this.ciphers = []; - await this.load(filter); + await this.load(filter, deleted); } async refresh() { From 3a10c1ff3027832953094b2f7edddb2361119b09 Mon Sep 17 00:00:00 2001 From: Chad Scharf <3904944+cscharf@users.noreply.github.com> Date: Wed, 8 Apr 2020 16:44:13 -0400 Subject: [PATCH 3/3] [Soft Delete] - cipher search rem deleted flag, filter array conditional --- src/abstractions/search.service.ts | 5 ++- src/angular/components/add-edit.component.ts | 44 ++++++++++++++++++-- src/angular/components/ciphers.component.ts | 7 ++-- src/angular/components/view.component.ts | 8 ++++ src/services/search.service.ts | 19 ++++----- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/abstractions/search.service.ts b/src/abstractions/search.service.ts index 3e146e94bcb..1cfc32b19cc 100644 --- a/src/abstractions/search.service.ts +++ b/src/abstractions/search.service.ts @@ -4,7 +4,8 @@ export abstract class SearchService { clearIndex: () => void; isSearchable: (query: string) => boolean; indexCiphers: () => Promise; - searchCiphers: (query: string, filter?: (cipher: CipherView) => boolean, - ciphers?: CipherView[], deleted?: boolean) => Promise; + searchCiphers: (query: string, + filter?: ((cipher: CipherView) => boolean) | (Array<(cipher: CipherView) => boolean>), + ciphers?: CipherView[]) => Promise; searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[]; } diff --git a/src/angular/components/add-edit.component.ts b/src/angular/components/add-edit.component.ts index e0bbc59dc45..40d3261d632 100644 --- a/src/angular/components/add-edit.component.ts +++ b/src/angular/components/add-edit.component.ts @@ -50,6 +50,7 @@ export class AddEditComponent implements OnInit { @Input() organizationId: string = null; @Output() onSavedCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); + @Output() onRestoredCipher = new EventEmitter(); @Output() onCancelled = new EventEmitter(); @Output() onEditAttachments = new EventEmitter(); @Output() onShareCipher = new EventEmitter(); @@ -63,6 +64,7 @@ export class AddEditComponent implements OnInit { title: string; formPromise: Promise; deletePromise: Promise; + restorePromise: Promise; checkPasswordPromise: Promise; showPassword: boolean = false; showCardCode: boolean = false; @@ -221,6 +223,10 @@ export class AddEditComponent implements OnInit { } async submit(): Promise { + if (this.cipher.isDeleted) { + return this.restore(); + } + if (this.cipher.name == null || this.cipher.name === '') { this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), this.i18nService.t('nameRequired')); @@ -331,10 +337,35 @@ export class AddEditComponent implements OnInit { try { this.deletePromise = this.deleteCipher(); await this.deletePromise; - this.platformUtilsService.eventTrack('Deleted Cipher'); - this.platformUtilsService.showToast('success', null, this.i18nService.t('deletedItem')); + this.platformUtilsService.eventTrack((this.cipher.isDeleted ? 'Permanently ' : '') + 'Deleted Cipher'); + this.platformUtilsService.showToast('success', null, + this.i18nService.t(this.cipher.isDeleted ? 'permanentlyDeletedItem' : 'deletedItem')); this.onDeletedCipher.emit(this.cipher); - this.messagingService.send('deletedCipher'); + this.messagingService.send(this.cipher.isDeleted ? 'permanentlyDeletedCipher' : 'deletedCipher'); + } catch { } + + return true; + } + + async restore(): Promise { + if (!this.cipher.isDeleted) { + return false; + } + + 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.restorePromise = this.restoreCipher(); + await this.restorePromise; + this.platformUtilsService.eventTrack('Restored Cipher'); + this.platformUtilsService.showToast('success', null, this.i18nService.t('restoredItem')); + this.onRestoredCipher.emit(this.cipher); + this.messagingService.send('restoredCipher'); } catch { } return true; @@ -449,6 +480,11 @@ export class AddEditComponent implements OnInit { } protected deleteCipher() { - return this.cipherService.deleteWithServer(this.cipher.id); + return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id) + : this.cipherService.softDeleteWithServer(this.cipher.id); + } + + protected restoreCipher() { + return this.cipherService.restoreWithServer(this.cipher.id); } } diff --git a/src/angular/components/ciphers.component.ts b/src/angular/components/ciphers.component.ts index 7244ca6e215..50beab2f543 100644 --- a/src/angular/components/ciphers.component.ts +++ b/src/angular/components/ciphers.component.ts @@ -64,7 +64,7 @@ export class CiphersComponent { async refresh() { try { this.refreshing = true; - await this.reload(this.filter); + await this.reload(this.filter, this.deleted); } finally { this.refreshing = false; } @@ -80,14 +80,15 @@ export class CiphersComponent { if (this.searchTimeout != null) { clearTimeout(this.searchTimeout); } + const deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; if (timeout == null) { - this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter, null, this.deleted); + this.ciphers = await this.searchService.searchCiphers(this.searchText, [this.filter, deletedFilter], null); await this.resetPaging(); return; } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter, null, this.deleted); + this.ciphers = await this.searchService.searchCiphers(this.searchText, [this.filter, deletedFilter], null); await this.resetPaging(); this.searchPending = false; }, timeout); diff --git a/src/angular/components/view.component.ts b/src/angular/components/view.component.ts index a0d6076483b..d325a5f8950 100644 --- a/src/angular/components/view.component.ts +++ b/src/angular/components/view.component.ts @@ -34,6 +34,7 @@ export class ViewComponent implements OnDestroy, OnInit { @Input() cipherId: string; @Output() onEditCipher = new EventEmitter(); @Output() onCloneCipher = new EventEmitter(); + @Output() onRestoreCipher = new EventEmitter(); cipher: CipherView; showPassword: boolean; @@ -110,6 +111,13 @@ export class ViewComponent implements OnDestroy, OnInit { this.onCloneCipher.emit(this.cipher); } + restore() { + if (!this.cipher.isDeleted) { + return; + } + this.onRestoreCipher.emit(this.cipher); + } + togglePassword() { this.platformUtilsService.eventTrack('Toggled Password'); this.showPassword = !this.showPassword; diff --git a/src/services/search.service.ts b/src/services/search.service.ts index ff1f2bf994d..4a994c411d9 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -71,8 +71,9 @@ export class SearchService implements SearchServiceAbstraction { console.timeEnd('search indexing'); } - async searchCiphers(query: string, filter: (cipher: CipherView) => boolean = null, ciphers: CipherView[] = null, - deleted: boolean = false): + async searchCiphers(query: string, + filter: (((cipher: CipherView) => boolean) | (Array<(cipher: CipherView) => boolean>)) = null, + ciphers: CipherView[] = null): Promise { const results: CipherView[] = []; if (query != null) { @@ -86,15 +87,11 @@ export class SearchService implements SearchServiceAbstraction { ciphers = await this.cipherService.getAllDecrypted(); } - ciphers = ciphers.filter((c) => { - if (deleted !== c.isDeleted) { - return false; - } - if (filter != null) { - return filter(c); - } - return true; - }); + if (filter != null && Array.isArray(filter) && filter.length > 0) { + ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c))); + } else if (filter != null) { + ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); + } if (!this.isSearchable(query)) { return ciphers;