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] [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 ac2d913b39..1fc71b9c1c 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 97c9c37abc..3c55b2cdbf 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 1ef8f92cd0..3e146e94bc 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 b7220f514c..d00bd1ca73 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 9983823dfa..022da692d5 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 e81371c4e8..1de1fbf267 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 eed006e915..40f626e751 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 19478debad..9a45c73dae 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 85a961dd8f..647251f4ea 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 0000000000..546cc92450 --- /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 54584123d4..580e21fa7e 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 14feea8c42..e1c8d5fa66 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 b57ea4b824..70a91f36c3 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 6e7296e09c..d2856d7449 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 1977924d5c..ff1f2bf994 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; }