Merge pull request #96 from bitwarden/soft-delete

Soft delete - update cipher search and added restore functionality
This commit is contained in:
Chad Scharf 2020-04-10 10:01:17 -04:00 committed by GitHub
commit 8d796dc3c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 219 additions and 18 deletions

View File

@ -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<any>;
postImportCiphers: (request: ImportCiphersRequest) => Promise<any>;
postImportOrganizationCiphers: (organizationId: string, request: ImportOrganizationCiphersRequest) => Promise<any>;
putDeleteCipher: (id: string) => Promise<any>;
putDeleteCipherAdmin: (id: string) => Promise<any>;
putDeleteManyCiphers: (request: CipherBulkDeleteRequest) => Promise<any>;
putRestoreCipher: (id: string) => Promise<any>;
putRestoreCipherAdmin: (id: string) => Promise<any>;
putRestoreManyCiphers: (request: CipherBulkRestoreRequest) => Promise<any>;
postCipherAttachment: (id: string, data: FormData) => Promise<CipherResponse>;
postCipherAttachmentAdmin: (id: string, data: FormData) => Promise<CipherResponse>;

View File

@ -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<any>;
softDeleteWithServer: (id: string) => Promise<any>;
softDeleteManyWithServer: (ids: string[]) => Promise<any>;
restore: (id: string | string[]) => Promise<any>;
restoreWithServer: (id: string) => Promise<any>;
restoreManyWithServer: (ids: string[]) => Promise<any>;
}

View File

@ -4,7 +4,8 @@ export abstract class SearchService {
clearIndex: () => void;
isSearchable: (query: string) => boolean;
indexCiphers: () => Promise<void>;
searchCiphers: (query: string, filter?: (cipher: CipherView) => boolean,
searchCiphers: (query: string,
filter?: ((cipher: CipherView) => boolean) | (Array<(cipher: CipherView) => boolean>),
ciphers?: CipherView[]) => Promise<CipherView[]>;
searchCiphersBasic: (ciphers: CipherView[], query: string) => CipherView[];
searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[];
}

View File

@ -50,6 +50,7 @@ export class AddEditComponent implements OnInit {
@Input() organizationId: string = null;
@Output() onSavedCipher = new EventEmitter<CipherView>();
@Output() onDeletedCipher = new EventEmitter<CipherView>();
@Output() onRestoredCipher = new EventEmitter<CipherView>();
@Output() onCancelled = new EventEmitter<CipherView>();
@Output() onEditAttachments = new EventEmitter<CipherView>();
@Output() onShareCipher = new EventEmitter<CipherView>();
@ -63,6 +64,7 @@ export class AddEditComponent implements OnInit {
title: string;
formPromise: Promise<any>;
deletePromise: Promise<any>;
restorePromise: Promise<any>;
checkPasswordPromise: Promise<number>;
showPassword: boolean = false;
showCardCode: boolean = false;
@ -221,6 +223,10 @@ export class AddEditComponent implements OnInit {
}
async submit(): Promise<boolean> {
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<boolean> {
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);
}
}

View File

@ -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;
}
@ -53,16 +55,16 @@ 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() {
try {
this.refreshing = true;
await this.reload(this.filter);
await this.reload(this.filter, this.deleted);
} finally {
this.refreshing = false;
}
@ -78,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);
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);
this.ciphers = await this.searchService.searchCiphers(this.searchText, [this.filter, deletedFilter], null);
await this.resetPaging();
this.searchPending = false;
}, timeout);

View File

@ -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<CipherType>();
@Output() onFolderClicked = new EventEmitter<FolderView>();
@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;

View File

@ -34,6 +34,7 @@ export class ViewComponent implements OnDestroy, OnInit {
@Input() cipherId: string;
@Output() onEditCipher = new EventEmitter<CipherView>();
@Output() onCloneCipher = new EventEmitter<CipherView>();
@Output() onRestoreCipher = new EventEmitter<CipherView>();
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;

View File

@ -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;
}

View File

@ -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,

View File

@ -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:

View File

@ -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,

View File

@ -0,0 +1,7 @@
export class CipherBulkRestoreRequest {
ids: string[];
constructor(ids: string[]) {
this.ids = ids == null ? [] : ids;
}
}

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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<any> {
return this.send('PUT', '/ciphers/' + id + '/delete', null, true, false);
}
putDeleteCipherAdmin(id: string): Promise<any> {
return this.send('PUT', '/ciphers/' + id + '/delete-admin', null, true, false);
}
putDeleteManyCiphers(request: CipherBulkDeleteRequest): Promise<any> {
return this.send('PUT', '/ciphers/delete', request, true, false);
}
putRestoreCipher(id: string): Promise<any> {
return this.send('PUT', '/ciphers/' + id + '/restore', null, true, false);
}
putRestoreCipherAdmin(id: string): Promise<any> {
return this.send('PUT', '/ciphers/' + id + '/restore-admin', null, true, false);
}
putRestoreManyCiphers(request: CipherBulkDeleteRequest): Promise<any> {
return this.send('PUT', '/ciphers/restore', request, true, false);
}
// Attachments APIs
async postCipherAttachment(id: string, data: FormData): Promise<CipherResponse> {

View File

@ -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<any> {
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<any> {
await this.apiService.putDeleteCipher(id);
await this.softDelete(id);
}
async softDeleteManyWithServer(ids: string[]): Promise<any> {
await this.apiService.putDeleteManyCiphers(new CipherBulkDeleteRequest(ids));
await this.softDelete(ids);
}
async restore(id: string | string[]): Promise<any> {
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<any> {
await this.apiService.putRestoreCipher(id);
await this.restore(id);
}
async restoreManyWithServer(ids: string[]): Promise<any> {
await this.apiService.putRestoreManyCiphers(new CipherBulkRestoreRequest(ids));
await this.restore(ids);
}
// Helpers
private async shareAttachmentWithServer(attachmentView: AttachmentView, cipherId: string,

View File

@ -71,7 +71,9 @@ 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) | (Array<(cipher: CipherView) => boolean>)) = null,
ciphers: CipherView[] = null):
Promise<CipherView[]> {
const results: CipherView[] = [];
if (query != null) {
@ -84,8 +86,11 @@ export class SearchService implements SearchServiceAbstraction {
if (ciphers == null) {
ciphers = await this.cipherService.getAllDecrypted();
}
if (filter != null) {
ciphers = ciphers.filter(filter);
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)) {
@ -138,9 +143,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;
}