Merge pull request #527 from bitwarden/soft-delete

Soft delete feature
This commit is contained in:
Chad Scharf 2020-05-08 11:17:02 -04:00 committed by GitHub
commit e3464da19a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 393 additions and 95 deletions

View File

@ -145,6 +145,7 @@ import { AddEditComponent } from './vault/add-edit.component';
import { AttachmentsComponent } from './vault/attachments.component'; import { AttachmentsComponent } from './vault/attachments.component';
import { BulkDeleteComponent } from './vault/bulk-delete.component'; import { BulkDeleteComponent } from './vault/bulk-delete.component';
import { BulkMoveComponent } from './vault/bulk-move.component'; import { BulkMoveComponent } from './vault/bulk-move.component';
import { BulkRestoreComponent } from './vault/bulk-restore.component';
import { BulkShareComponent } from './vault/bulk-share.component'; import { BulkShareComponent } from './vault/bulk-share.component';
import { CiphersComponent } from './vault/ciphers.component'; import { CiphersComponent } from './vault/ciphers.component';
import { CollectionsComponent } from './vault/collections.component'; import { CollectionsComponent } from './vault/collections.component';
@ -257,6 +258,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
BreachReportComponent, BreachReportComponent,
BulkDeleteComponent, BulkDeleteComponent,
BulkMoveComponent, BulkMoveComponent,
BulkRestoreComponent,
BulkShareComponent, BulkShareComponent,
CalloutComponent, CalloutComponent,
ChangeEmailComponent, ChangeEmailComponent,
@ -375,6 +377,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
AttachmentsComponent, AttachmentsComponent,
BulkDeleteComponent, BulkDeleteComponent,
BulkMoveComponent, BulkMoveComponent,
BulkRestoreComponent,
BulkShareComponent, BulkShareComponent,
CollectionsComponent, CollectionsComponent,
DeauthorizeSessionsComponent, DeauthorizeSessionsComponent,

View File

@ -94,6 +94,7 @@ export class AddEditComponent extends BaseAddEditComponent {
if (!this.organization.isAdmin) { if (!this.organization.isAdmin) {
return super.deleteCipher(); return super.deleteCipher();
} }
return this.apiService.deleteCipherAdmin(this.cipherId); return this.cipher.isDeleted ? this.apiService.deleteCipherAdmin(this.cipherId)
: this.apiService.putDeleteCipherAdmin(this.cipherId);
} }
} }

View File

@ -41,7 +41,7 @@ export class CiphersComponent extends BaseCiphersComponent {
async load(filter: (cipher: CipherView) => boolean = null) { async load(filter: (cipher: CipherView) => boolean = null) {
if (!this.organization.isAdmin) { if (!this.organization.isAdmin) {
await super.load(filter); await super.load(filter, this.deleted);
return; return;
} }
this.accessEvents = this.organization.useEvents; this.accessEvents = this.organization.useEvents;
@ -65,13 +65,19 @@ export class CiphersComponent extends BaseCiphersComponent {
} }
this.searchPending = false; this.searchPending = false;
let filteredCiphers = this.allCiphers; let filteredCiphers = this.allCiphers;
if (this.filter != null) {
filteredCiphers = filteredCiphers.filter(this.filter);
}
if (this.searchText == null || this.searchText.trim().length < 2) { 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 { } 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(); await this.resetPaging();
} }
@ -86,9 +92,9 @@ export class CiphersComponent extends BaseCiphersComponent {
protected deleteCipher(id: string) { protected deleteCipher(id: string) {
if (!this.organization.isAdmin) { 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) { protected showFixOldAttachments(c: CipherView) {

View File

@ -1,9 +1,10 @@
<div class="container page-content"> <div class="container page-content">
<div class="row"> <div class="row">
<div class="col-3"> <div class="col-3">
<app-org-vault-groupings [showFolders]="false" [showFavorites]="false" <app-org-vault-groupings [showFolders]="false" [showFavorites]="false" [showTrash]="true"
(onAllClicked)="clearGroupingFilters()" (onCipherTypeClicked)="filterCipherType($event)" (onAllClicked)="clearGroupingFilters()" (onCipherTypeClicked)="filterCipherType($event)"
(onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)"> (onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)"
(onTrashClicked)="filterDeleted()">
</app-org-vault-groupings> </app-org-vault-groupings>
</div> </div>
<div class="col-9"> <div class="col-9">
@ -18,7 +19,8 @@
</ng-container> </ng-container>
</small> </small>
</h1> </h1>
<button type="button" class="btn btn-outline-primary btn-sm ml-auto" (click)="addCipher()"> <button type="button" class="btn btn-outline-primary btn-sm ml-auto" (click)="addCipher()"
*ngIf="!deleted">
<i class="fa fa-plus fa-fw" aria-hidden="true"></i>{{'addItem' | i18n}} <i class="fa fa-plus fa-fw" aria-hidden="true"></i>{{'addItem' | i18n}}
</button> </button>
</div> </div>
@ -33,4 +35,4 @@
<ng-template #attachments></ng-template> <ng-template #attachments></ng-template>
<ng-template #cipherAddEdit></ng-template> <ng-template #cipherAddEdit></ng-template>
<ng-template #collections></ng-template> <ng-template #collections></ng-template>
<ng-template #eventsTemplate></ng-template> <ng-template #eventsTemplate></ng-template>

View File

@ -49,8 +49,9 @@ export class VaultComponent implements OnInit, OnDestroy {
@ViewChild('eventsTemplate', { read: ViewContainerRef }) eventsModalRef: ViewContainerRef; @ViewChild('eventsTemplate', { read: ViewContainerRef }) eventsModalRef: ViewContainerRef;
organization: Organization; organization: Organization;
collectionId: string; collectionId: string = null;
type: CipherType; type: CipherType = null;
deleted: boolean = false;
private modal: ModalComponent = null; private modal: ModalComponent = null;
@ -61,7 +62,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private broadcasterService: BroadcasterService, private ngZone: NgZone) { } private broadcasterService: BroadcasterService, private ngZone: NgZone) { }
ngOnInit() { 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.organization = await this.userService.getOrganization(params.organizationId);
this.groupingsComponent.organization = this.organization; this.groupingsComponent.organization = this.organization;
this.ciphersComponent.organization = this.organization; this.ciphersComponent.organization = this.organization;
@ -92,7 +93,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.groupingsComponent.selectedAll = true; this.groupingsComponent.selectedAll = true;
await this.ciphersComponent.reload(); await this.ciphersComponent.reload();
} else { } 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); const t = parseInt(qParams.type, null);
this.groupingsComponent.selectedType = t; this.groupingsComponent.selectedType = t;
await this.filterCipherType(t, true); await this.filterCipherType(t, true);
@ -116,6 +120,10 @@ export class VaultComponent implements OnInit, OnDestroy {
queryParamsSub.unsubscribe(); queryParamsSub.unsubscribe();
} }
}); });
if (queryParams != null) {
queryParams.unsubscribe();
}
}); });
} }
@ -125,6 +133,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async clearGroupingFilters() { async clearGroupingFilters() {
this.ciphersComponent.showAddNew = true; this.ciphersComponent.showAddNew = true;
this.ciphersComponent.deleted = false;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchVault'); this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchVault');
await this.ciphersComponent.applyFilter(); await this.ciphersComponent.applyFilter();
this.clearFilters(); this.clearFilters();
@ -133,6 +142,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async filterCipherType(type: CipherType, load = false) { async filterCipherType(type: CipherType, load = false) {
this.ciphersComponent.showAddNew = true; this.ciphersComponent.showAddNew = true;
this.ciphersComponent.deleted = false;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType'); this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType');
const filter = (c: CipherView) => c.type === type; const filter = (c: CipherView) => c.type === type;
if (load) { if (load) {
@ -147,6 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async filterCollection(collectionId: string, load = false) { async filterCollection(collectionId: string, load = false) {
this.ciphersComponent.showAddNew = true; this.ciphersComponent.showAddNew = true;
this.ciphersComponent.deleted = false;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchCollection'); this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchCollection');
const filter = (c: CipherView) => { const filter = (c: CipherView) => {
if (collectionId === 'unassigned') { if (collectionId === 'unassigned') {
@ -165,6 +176,20 @@ export class VaultComponent implements OnInit, OnDestroy {
this.go(); 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) { filterSearchText(searchText: string) {
this.ciphersComponent.searchText = searchText; this.ciphersComponent.searchText = searchText;
this.ciphersComponent.search(200); this.ciphersComponent.search(200);
@ -255,6 +280,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.modal.close(); this.modal.close();
await this.ciphersComponent.refresh(); await this.ciphersComponent.refresh();
}); });
childComponent.onRestoredCipher.subscribe(async (c: CipherView) => {
this.modal.close();
await this.ciphersComponent.refresh();
});
this.modal.onClosed.subscribe(() => { this.modal.onClosed.subscribe(() => {
this.modal = null; this.modal = null;
@ -299,6 +328,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private clearFilters() { private clearFilters() {
this.collectionId = null; this.collectionId = null;
this.type = null; this.type = null;
this.deleted = false;
} }
private go(queryParams: any = null) { private go(queryParams: any = null) {
@ -306,6 +336,7 @@ export class VaultComponent implements OnInit, OnDestroy {
queryParams = { queryParams = {
type: this.type, type: this.type,
collectionId: this.collectionId, collectionId: this.collectionId,
deleted: this.deleted ? true : null,
}; };
} }

View File

@ -73,8 +73,14 @@ export class EventService {
msg = this.i18nService.t('editedItemId', this.formatCipherId(ev, options)); msg = this.i18nService.t('editedItemId', this.formatCipherId(ev, options));
break; break;
case EventType.Cipher_Deleted: 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)); msg = this.i18nService.t('deletedItemId', this.formatCipherId(ev, options));
break; break;
case EventType.Cipher_Restored:
msg = this.i18nService.t('restoredItemId', this.formatCipherId(ev, options));
break;
case EventType.Cipher_AttachmentCreated: case EventType.Cipher_AttachmentCreated:
msg = this.i18nService.t('createdAttachmentForItem', this.formatCipherId(ev, options)); msg = this.i18nService.t('createdAttachmentForItem', this.formatCipherId(ev, options));
break; break;

View File

@ -62,6 +62,10 @@ export class CipherReportComponent {
this.modal.close(); this.modal.close();
await this.load(); await this.load();
}); });
childComponent.onRestoredCipher.subscribe(async (c: CipherView) => {
this.modal.close();
await this.load();
});
this.modal.onClosed.subscribe(() => { this.modal.onClosed.subscribe(() => {
this.modal = null; this.modal = null;

View File

@ -12,7 +12,8 @@
<div class="row" *ngIf="!editMode"> <div class="row" *ngIf="!editMode">
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="type">{{'whatTypeOfItem' | i18n}}</label> <label for="type">{{'whatTypeOfItem' | i18n}}</label>
<select id="type" name="Type" [(ngModel)]="cipher.type" class="form-control"> <select id="type" name="Type" [(ngModel)]="cipher.type" class="form-control"
[disabled]="cipher.isDeleted">
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{o.name}}</option> <option *ngFor="let o of typeOptions" [ngValue]="o.value">{{o.name}}</option>
</select> </select>
</div> </div>
@ -21,11 +22,12 @@
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="name">{{'name' | i18n}}</label> <label for="name">{{'name' | i18n}}</label>
<input id="name" class="form-control" type="text" name="Name" [(ngModel)]="cipher.name" <input id="name" class="form-control" type="text" name="Name" [(ngModel)]="cipher.name"
required> required [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group" *ngIf="!organization"> <div class="col-6 form-group" *ngIf="!organization">
<label for="folder">{{'folder' | i18n}}</label> <label for="folder">{{'folder' | i18n}}</label>
<select id="folder" name="FolderId" [(ngModel)]="cipher.folderId" class="form-control"> <select id="folder" name="FolderId" [(ngModel)]="cipher.folderId" class="form-control"
[disabled]="cipher.isDeleted">
<option *ngFor="let f of folders" [ngValue]="f.id">{{f.name}}</option> <option *ngFor="let f of folders" [ngValue]="f.id">{{f.name}}</option>
</select> </select>
</div> </div>
@ -37,8 +39,8 @@
<label for="loginUsername">{{'username' | i18n}}</label> <label for="loginUsername">{{'username' | i18n}}</label>
<div class="input-group"> <div class="input-group">
<input id="loginUsername" class="form-control" type="text" name="Login.Username" <input id="loginUsername" class="form-control" type="text" name="Login.Username"
[(ngModel)]="cipher.login.username" appInputVerbatim> [(ngModel)]="cipher.login.username" appInputVerbatim [disabled]="cipher.isDeleted">
<div class="input-group-append"> <div class="input-group-append" *ngIf="!cipher.isDeleted">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyUsername' | i18n}}" appA11yTitle="{{'copyUsername' | i18n}}"
(click)="copy(cipher.login.username, 'username', 'Username')" tabindex="-1"> (click)="copy(cipher.login.username, 'username', 'Username')" tabindex="-1">
@ -50,7 +52,7 @@
<div class="col-6 form-group"> <div class="col-6 form-group">
<div class="d-flex"> <div class="d-flex">
<label for="loginPassword">{{'password' | i18n}}</label> <label for="loginPassword">{{'password' | i18n}}</label>
<div class="ml-auto d-flex"> <div class="ml-auto d-flex" *ngIf="!cipher.isDeleted">
<a href="#" class="d-block mr-2" appStopClick <a href="#" class="d-block mr-2" appStopClick
appA11yTitle="{{'generatePassword' | i18n}}" (click)="generatePassword()"> appA11yTitle="{{'generatePassword' | i18n}}" (click)="generatePassword()">
<i class="fa fa-lg fa-fw fa-refresh" aria-hidden="true"></i> <i class="fa fa-lg fa-fw fa-refresh" aria-hidden="true"></i>
@ -68,7 +70,8 @@
<div class="input-group"> <div class="input-group">
<input id="loginPassword" class="form-control text-monospace" <input id="loginPassword" class="form-control text-monospace"
type="{{showPassword ? 'text' : 'password'}}" name="Login.Password" type="{{showPassword ? 'text' : 'password'}}" name="Login.Password"
[(ngModel)]="cipher.login.password" appInputVerbatim autocomplete="new-password"> [(ngModel)]="cipher.login.password" appInputVerbatim autocomplete="new-password"
[disabled]="cipher.isDeleted">
<div class="input-group-append"> <div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="togglePassword()" appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="togglePassword()"
@ -89,7 +92,7 @@
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="loginTotp">{{'authenticatorKeyTotp' | i18n}}</label> <label for="loginTotp">{{'authenticatorKeyTotp' | i18n}}</label>
<input id="loginTotp" type="text" name="Login.Totp" class="form-control text-monospace" <input id="loginTotp" type="text" name="Login.Totp" class="form-control text-monospace"
[(ngModel)]="cipher.login.totp" appInputVerbatim> [(ngModel)]="cipher.login.totp" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group totp d-flex align-items-end" [ngClass]="{'low': totpLow}"> <div class="col-6 form-group totp d-flex align-items-end" [ngClass]="{'low': totpLow}">
<div *ngIf="!cipher.login.totp || !totpCode"> <div *ngIf="!cipher.login.totp || !totpCode">
@ -132,7 +135,7 @@
<label for="loginUri{{i}}">{{'uriPosition' | i18n : (i + 1)}}</label> <label for="loginUri{{i}}">{{'uriPosition' | i18n : (i + 1)}}</label>
<div class="input-group"> <div class="input-group">
<input class="form-control" id="loginUri{{i}}" type="text" <input class="form-control" id="loginUri{{i}}" type="text"
name="Login.Uris[{{i}}].Uri" [(ngModel)]="u.uri" name="Login.Uris[{{i}}].Uri" [(ngModel)]="u.uri" [disabled]="cipher.isDeleted"
placeholder="{{'ex' | i18n}} https://google.com" appInputVerbatim> placeholder="{{'ex' | i18n}} https://google.com" appInputVerbatim>
<div class="input-group-append"> <div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
@ -160,19 +163,20 @@
</div> </div>
<div class="d-flex"> <div class="d-flex">
<select class="form-control" id="loginUriMatch{{i}}" name="Login.Uris[{{i}}].Match" <select class="form-control" id="loginUriMatch{{i}}" name="Login.Uris[{{i}}].Match"
[(ngModel)]="u.match" (change)="loginUriMatchChanged(u)"> [(ngModel)]="u.match" (change)="loginUriMatchChanged(u)"
[disabled]="cipher.isDeleted">
<option *ngFor="let o of uriMatchOptions" [ngValue]="o.value">{{o.name}} <option *ngFor="let o of uriMatchOptions" [ngValue]="o.value">{{o.name}}
</option> </option>
</select> </select>
<button type="button" class="btn btn-link text-danger ml-2" (click)="removeUri(u)" <button type="button" class="btn btn-link text-danger ml-2" (click)="removeUri(u)"
appA11yTitle="{{'remove' | i18n}}"> appA11yTitle="{{'remove' | i18n}}" *ngIf="!cipher.isDeleted">
<i class="fa fa-minus-circle fa-lg" aria-hidden="true"></i> <i class="fa fa-minus-circle fa-lg" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</ng-container> </ng-container>
<a href="#" appStopClick (click)="addUri()" class="d-inline-block mb-3"> <a href="#" appStopClick (click)="addUri()" class="d-inline-block mb-3" *ngIf="!cipher.isDeleted">
<i class="fa fa-plus-circle fa-fw" aria-hidden="true"></i> {{'newUri' | i18n}} <i class="fa fa-plus-circle fa-fw" aria-hidden="true"></i> {{'newUri' | i18n}}
</a> </a>
</ng-container> </ng-container>
@ -182,12 +186,13 @@
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="cardCardholderName">{{'cardholderName' | i18n}}</label> <label for="cardCardholderName">{{'cardholderName' | i18n}}</label>
<input id="cardCardholderName" class="form-control" type="text" <input id="cardCardholderName" class="form-control" type="text"
name="Card.CardCardholderName" [(ngModel)]="cipher.card.cardholderName"> name="Card.CardCardholderName" [(ngModel)]="cipher.card.cardholderName"
[disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="cardBrand">{{'brand' | i18n}}</label> <label for="cardBrand">{{'brand' | i18n}}</label>
<select id="cardBrand" class="form-control" name="Card.Brand" <select id="cardBrand" class="form-control" name="Card.Brand"
[(ngModel)]="cipher.card.brand"> [(ngModel)]="cipher.card.brand" [disabled]="cipher.isDeleted">
<option *ngFor="let o of cardBrandOptions" [ngValue]="o.value">{{o.name}}</option> <option *ngFor="let o of cardBrandOptions" [ngValue]="o.value">{{o.name}}</option>
</select> </select>
</div> </div>
@ -197,7 +202,7 @@
<label for="cardNumber">{{'number' | i18n}}</label> <label for="cardNumber">{{'number' | i18n}}</label>
<div class="input-group"> <div class="input-group">
<input id="cardNumber" class="form-control" type="text" name="Card.Number" <input id="cardNumber" class="form-control" type="text" name="Card.Number"
[(ngModel)]="cipher.card.number" appInputVerbatim> [(ngModel)]="cipher.card.number" appInputVerbatim [disabled]="cipher.isDeleted">
<div class="input-group-append"> <div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyNumber' | i18n}}" appA11yTitle="{{'copyNumber' | i18n}}"
@ -210,14 +215,15 @@
<div class="col form-group"> <div class="col form-group">
<label for="cardExpMonth">{{'expirationMonth' | i18n}}</label> <label for="cardExpMonth">{{'expirationMonth' | i18n}}</label>
<select id="cardExpMonth" class="form-control" name="Card.ExpMonth" <select id="cardExpMonth" class="form-control" name="Card.ExpMonth"
[(ngModel)]="cipher.card.expMonth"> [(ngModel)]="cipher.card.expMonth" [disabled]="cipher.isDeleted">
<option *ngFor="let o of cardExpMonthOptions" [ngValue]="o.value">{{o.name}}</option> <option *ngFor="let o of cardExpMonthOptions" [ngValue]="o.value">{{o.name}}</option>
</select> </select>
</div> </div>
<div class="col form-group"> <div class="col form-group">
<label for="cardExpYear">{{'expirationYear' | i18n}}</label> <label for="cardExpYear">{{'expirationYear' | i18n}}</label>
<input id="cardExpYear" class="form-control" type="text" name="Card.ExpYear" <input id="cardExpYear" class="form-control" type="text" name="Card.ExpYear"
[(ngModel)]="cipher.card.expYear" placeholder="{{'ex' | i18n}} 2019"> [(ngModel)]="cipher.card.expYear" placeholder="{{'ex' | i18n}} 2019"
[disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -226,7 +232,8 @@
<div class="input-group"> <div class="input-group">
<input id="cardCode" class="form-control text-monospace" <input id="cardCode" class="form-control text-monospace"
type="{{showCardCode ? 'text' : 'password'}}" name="Card.Code" type="{{showCardCode ? 'text' : 'password'}}" name="Card.Code"
[(ngModel)]="cipher.card.code" appInputVerbatim autocomplete="new-password"> [(ngModel)]="cipher.card.code" appInputVerbatim autocomplete="new-password"
[disabled]="cipher.isDeleted">
<div class="input-group-append"> <div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleCardCode()" appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleCardCode()"
@ -250,7 +257,7 @@
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idTitle">{{'title' | i18n}}</label> <label for="idTitle">{{'title' | i18n}}</label>
<select id="idTitle" class="form-control" name="Identity.Title" <select id="idTitle" class="form-control" name="Identity.Title"
[(ngModel)]="cipher.identity.title"> [(ngModel)]="cipher.identity.title" [disabled]="cipher.isDeleted">
<option *ngFor="let o of identityTitleOptions" [ngValue]="o.value">{{o.name}}</option> <option *ngFor="let o of identityTitleOptions" [ngValue]="o.value">{{o.name}}</option>
</select> </select>
</div> </div>
@ -259,107 +266,107 @@
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idFirstName">{{'firstName' | i18n}}</label> <label for="idFirstName">{{'firstName' | i18n}}</label>
<input id="idFirstName" class="form-control" type="text" name="Identity.FirstName" <input id="idFirstName" class="form-control" type="text" name="Identity.FirstName"
[(ngModel)]="cipher.identity.firstName"> [(ngModel)]="cipher.identity.firstName" [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idMiddleName">{{'middleName' | i18n}}</label> <label for="idMiddleName">{{'middleName' | i18n}}</label>
<input id="idMiddleName" class="form-control" type="text" name="Identity.MiddleName" <input id="idMiddleName" class="form-control" type="text" name="Identity.MiddleName"
[(ngModel)]="cipher.identity.middleName"> [(ngModel)]="cipher.identity.middleName" [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idLastName">{{'lastName' | i18n}}</label> <label for="idLastName">{{'lastName' | i18n}}</label>
<input id="idLastName" class="form-control" type="text" name="Identity.LastName" <input id="idLastName" class="form-control" type="text" name="Identity.LastName"
[(ngModel)]="cipher.identity.lastName"> [(ngModel)]="cipher.identity.lastName" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idUsername">{{'username' | i18n}}</label> <label for="idUsername">{{'username' | i18n}}</label>
<input id="idUsername" class="form-control" type="text" name="Identity.Username" <input id="idUsername" class="form-control" type="text" name="Identity.Username"
[(ngModel)]="cipher.identity.username" appInputVerbatim> [(ngModel)]="cipher.identity.username" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idCompany">{{'company' | i18n}}</label> <label for="idCompany">{{'company' | i18n}}</label>
<input id="idCompany" class="form-control" type="text" name="Identity.Company" <input id="idCompany" class="form-control" type="text" name="Identity.Company"
[(ngModel)]="cipher.identity.company"> [(ngModel)]="cipher.identity.company" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idSsn">{{'ssn' | i18n}}</label> <label for="idSsn">{{'ssn' | i18n}}</label>
<input id="idSsn" class="form-control" type="text" name="Identity.SSN" <input id="idSsn" class="form-control" type="text" name="Identity.SSN"
[(ngModel)]="cipher.identity.ssn" appInputVerbatim> [(ngModel)]="cipher.identity.ssn" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idPassportNumber">{{'passportNumber' | i18n}}</label> <label for="idPassportNumber">{{'passportNumber' | i18n}}</label>
<input id="idPassportNumber" class="form-control" type="text" name="Identity.PassportNumber" <input id="idPassportNumber" class="form-control" type="text" name="Identity.PassportNumber"
[(ngModel)]="cipher.identity.passportNumber" appInputVerbatim> [(ngModel)]="cipher.identity.passportNumber" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-4 form-group"> <div class="col-4 form-group">
<label for="idLicenseNumber">{{'licenseNumber' | i18n}}</label> <label for="idLicenseNumber">{{'licenseNumber' | i18n}}</label>
<input id="idLicenseNumber" class="form-control" type="text" name="Identity.LicenseNumber" <input id="idLicenseNumber" class="form-control" type="text" name="Identity.LicenseNumber"
[(ngModel)]="cipher.identity.licenseNumber" appInputVerbatim> [(ngModel)]="cipher.identity.licenseNumber" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idEmail">{{'email' | i18n}}</label> <label for="idEmail">{{'email' | i18n}}</label>
<input id="idEmail" class="form-control" type="text" name="Identity.Email" <input id="idEmail" class="form-control" type="text" name="Identity.Email"
[(ngModel)]="cipher.identity.email" appInputVerbatim> [(ngModel)]="cipher.identity.email" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idPhone">{{'phone' | i18n}}</label> <label for="idPhone">{{'phone' | i18n}}</label>
<input id="idPhone" class="form-control" type="text" name="Identity.Phone" <input id="idPhone" class="form-control" type="text" name="Identity.Phone"
[(ngModel)]="cipher.identity.phone"> [(ngModel)]="cipher.identity.phone" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idAddress1">{{'address1' | i18n}}</label> <label for="idAddress1">{{'address1' | i18n}}</label>
<input id="idAddress1" class="form-control" type="text" name="Identity.Address1" <input id="idAddress1" class="form-control" type="text" name="Identity.Address1"
[(ngModel)]="cipher.identity.address1"> [(ngModel)]="cipher.identity.address1" [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idAddress2">{{'address2' | i18n}}</label> <label for="idAddress2">{{'address2' | i18n}}</label>
<input id="idAddress2" class="form-control" type="text" name="Identity.Address2" <input id="idAddress2" class="form-control" type="text" name="Identity.Address2"
[(ngModel)]="cipher.identity.address2"> [(ngModel)]="cipher.identity.address2" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idAddress3">{{'address3' | i18n}}</label> <label for="idAddress3">{{'address3' | i18n}}</label>
<input id="idAddress3" class="form-control" type="text" name="Identity.Address3" <input id="idAddress3" class="form-control" type="text" name="Identity.Address3"
[(ngModel)]="cipher.identity.address3"> [(ngModel)]="cipher.identity.address3" [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idCity">{{'cityTown' | i18n}}</label> <label for="idCity">{{'cityTown' | i18n}}</label>
<input id="idCity" class="form-control" type="text" name="Identity.City" <input id="idCity" class="form-control" type="text" name="Identity.City"
[(ngModel)]="cipher.identity.city"> [(ngModel)]="cipher.identity.city" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idState">{{'stateProvince' | i18n}}</label> <label for="idState">{{'stateProvince' | i18n}}</label>
<input id="idState" class="form-control" type="text" name="Identity.State" <input id="idState" class="form-control" type="text" name="Identity.State"
[(ngModel)]="cipher.identity.state"> [(ngModel)]="cipher.identity.state" [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idPostalCode">{{'zipPostalCode' | i18n}}</label> <label for="idPostalCode">{{'zipPostalCode' | i18n}}</label>
<input id="idPostalCode" class="form-control" type="text" name="Identity.PostalCode" <input id="idPostalCode" class="form-control" type="text" name="Identity.PostalCode"
[(ngModel)]="cipher.identity.postalCode"> [(ngModel)]="cipher.identity.postalCode" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 form-group"> <div class="col-6 form-group">
<label for="idCountry">{{'country' | i18n}}</label> <label for="idCountry">{{'country' | i18n}}</label>
<input id="idCountry" class="form-control" type="text" name="Identity.Country" <input id="idCountry" class="form-control" type="text" name="Identity.Country"
[(ngModel)]="cipher.identity.country"> [(ngModel)]="cipher.identity.country" [disabled]="cipher.isDeleted">
</div> </div>
</div> </div>
</ng-container> </ng-container>
<div class="form-group"> <div class="form-group">
<label for="notes">{{'notes' | i18n}}</label> <label for="notes">{{'notes' | i18n}}</label>
<textarea id="notes" name="Notes" rows="6" [(ngModel)]="cipher.notes" <textarea id="notes" name="Notes" rows="6" [(ngModel)]="cipher.notes" [disabled]="cipher.isDeleted"
class="form-control"></textarea> class="form-control"></textarea>
</div> </div>
<h3 class="mt-4">{{'customFields' | i18n}}</h3> <h3 class="mt-4">{{'customFields' | i18n}}</h3>
@ -374,14 +381,14 @@
</a> </a>
</div> </div>
<input id="fieldName{{i}}" type="text" name="Field.Name{{i}}" [(ngModel)]="f.name" <input id="fieldName{{i}}" type="text" name="Field.Name{{i}}" [(ngModel)]="f.name"
class="form-control" appInputVerbatim> class="form-control" appInputVerbatim [disabled]="cipher.isDeleted">
</div> </div>
<div class="col-7 form-group"> <div class="col-7 form-group">
<label for="fieldValue{{i}}">{{'value' | i18n}}</label> <label for="fieldValue{{i}}">{{'value' | i18n}}</label>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="input-group" *ngIf="f.type === fieldType.Text"> <div class="input-group" *ngIf="f.type === fieldType.Text">
<input id="fieldValue{{i}}" class="form-control" type="text" name="Field.Value{{i}}" <input id="fieldValue{{i}}" class="form-control" type="text" name="Field.Value{{i}}"
[(ngModel)]="f.value" appInputVerbatim> [(ngModel)]="f.value" appInputVerbatim [disabled]="cipher.isDeleted">
<div class="input-group-append"> <div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyValue' | i18n}}" appA11yTitle="{{'copyValue' | i18n}}"
@ -394,7 +401,7 @@
<input id="fieldValue{{i}}" type="{{f.showValue ? 'text' : 'password'}}" <input id="fieldValue{{i}}" type="{{f.showValue ? 'text' : 'password'}}"
name="Field.Value{{i}}" [(ngModel)]="f.value" name="Field.Value{{i}}" [(ngModel)]="f.value"
class="form-control text-monospace" appInputVerbatim class="form-control text-monospace" appInputVerbatim
autocomplete="new-password"> autocomplete="new-password" [disabled]="cipher.isDeleted">
<div class="input-group-append"> <div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleFieldValue(f)" appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleFieldValue(f)"
@ -414,24 +421,24 @@
<div class="flex-fill"> <div class="flex-fill">
<input id="fieldValue{{i}}" name="Field.Value{{i}}" type="checkbox" <input id="fieldValue{{i}}" name="Field.Value{{i}}" type="checkbox"
[(ngModel)]="f.value" *ngIf="f.type === fieldType.Boolean" appTrueFalseValue [(ngModel)]="f.value" *ngIf="f.type === fieldType.Boolean" appTrueFalseValue
trueValue="true" falseValue="false"> trueValue="true" falseValue="false" [disabled]="cipher.isDeleted">
</div> </div>
<button type="button" class="btn btn-link text-danger ml-2" (click)="removeField(f)" <button type="button" class="btn btn-link text-danger ml-2" (click)="removeField(f)"
appA11yTitle="{{'remove' | i18n}}"> appA11yTitle="{{'remove' | i18n}}" *ngIf="!cipher.isDeleted">
<i class="fa fa-minus-circle fa-lg" aria-hidden="true"></i> <i class="fa fa-minus-circle fa-lg" aria-hidden="true"></i>
</button> </button>
<button type="button" class="btn btn-link text-muted cursor-move" <button type="button" class="btn btn-link text-muted cursor-move"
appA11yTitle="{{'dragToSort' | i18n}}"> appA11yTitle="{{'dragToSort' | i18n}}" *ngIf="!cipher.isDeleted">
<i class="fa fa-bars fa-lg" aria-hidden="true"></i> <i class="fa fa-bars fa-lg" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<a href="#" appStopClick (click)="addField()" class="d-inline-block mb-2"> <a href="#" appStopClick (click)="addField()" class="d-inline-block mb-2" *ngIf="!cipher.isDeleted">
<i class="fa fa-plus-circle fa-fw" aria-hidden="true"></i> {{'newCustomField' | i18n}} <i class="fa fa-plus-circle fa-fw" aria-hidden="true"></i> {{'newCustomField' | i18n}}
</a> </a>
<div class="row"> <div class="row" *ngIf="!cipher.isDeleted">
<div class="col-5"> <div class="col-5">
<label for="addFieldType" class="sr-only">{{'type' | i18n}}</label> <label for="addFieldType" class="sr-only">{{'type' | i18n}}</label>
<select id="addFieldType" class="form-control" name="AddFieldType" [(ngModel)]="addFieldType"> <select id="addFieldType" class="form-control" name="AddFieldType" [(ngModel)]="addFieldType">
@ -445,7 +452,8 @@
<div class="col-5"> <div class="col-5">
<label for="organizationId">{{'whoOwnsThisItem' | i18n}}</label> <label for="organizationId">{{'whoOwnsThisItem' | i18n}}</label>
<select id="organizationId" class="form-control" name="OrganizationId" <select id="organizationId" class="form-control" name="OrganizationId"
[(ngModel)]="cipher.organizationId" (change)="organizationChanged()"> [(ngModel)]="cipher.organizationId" (change)="organizationChanged()"
[disabled]="cipher.isDeleted">
<option *ngFor="let o of ownershipOptions" [ngValue]="o.value">{{o.name}}</option> <option *ngFor="let o of ownershipOptions" [ngValue]="o.value">{{o.name}}</option>
</select> </select>
</div> </div>
@ -459,7 +467,7 @@
<ng-container *ngIf="collections && collections.length"> <ng-container *ngIf="collections && collections.length">
<div class="form-check" *ngFor="let c of collections; let i = index"> <div class="form-check" *ngFor="let c of collections; let i = index">
<input class="form-check-input" type="checkbox" [(ngModel)]="c.checked" <input class="form-check-input" type="checkbox" [(ngModel)]="c.checked"
id="collection-{{i}}" name="Collection[{{i}}].Checked"> id="collection-{{i}}" name="Collection[{{i}}].Checked" [disabled]="cipher.isDeleted">
<label class="form-check-label" for="collection-{{i}}">{{c.name}}</label> <label class="form-check-label" for="collection-{{i}}">{{c.name}}</label>
</div> </div>
</ng-container> </ng-container>
@ -492,19 +500,20 @@
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading"> <button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i> <i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'save' | i18n}}</span> <span>{{(cipher.isDeleted ? 'restore' : 'save') | i18n}}</span>
</button> </button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal"> <button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{'cancel' | i18n}} {{'cancel' | i18n}}
</button> </button>
<div class="ml-auto" *ngIf="cipher"> <div class="ml-auto" *ngIf="cipher">
<button *ngIf="!organization" type="button" (click)="toggleFavorite()" class="btn btn-link" <button *ngIf="!organization && !cipher.isDeleted" type="button" (click)="toggleFavorite()" class="btn btn-link"
appA11yTitle="{{(cipher.favorite ? 'unfavorite' : 'favorite') | i18n}}"> appA11yTitle="{{(cipher.favorite ? 'unfavorite' : 'favorite') | i18n}}">
<i class="fa fa-lg" [ngClass]="{'fa-star': cipher.favorite, 'fa-star-o': !cipher.favorite}" <i class="fa fa-lg" [ngClass]="{'fa-star': cipher.favorite, 'fa-star-o': !cipher.favorite}"
aria-hidden="true"></i> aria-hidden="true"></i>
</button> </button>
<button #deleteBtn type="button" (click)="delete()" class="btn btn-outline-danger" <button #deleteBtn type="button" (click)="delete()" class="btn btn-outline-danger"
appA11yTitle="{{'delete' | i18n}}" *ngIf="editMode && !cloneMode" [disabled]="deleteBtn.loading" appA11yTitle="{{(cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n}}"
*ngIf="editMode && !cloneMode" [disabled]="deleteBtn.loading"
[appApiAction]="deletePromise"> [appApiAction]="deletePromise">
<i class="fa fa-trash-o fa-lg fa-fw" [hidden]="deleteBtn.loading" aria-hidden="true"></i> <i class="fa fa-trash-o fa-lg fa-fw" [hidden]="deleteBtn.loading" aria-hidden="true"></i>
<i class="fa fa-spinner fa-spin fa-lg fa-fw" [hidden]="!deleteBtn.loading" <i class="fa fa-spinner fa-spin fa-lg fa-fw" [hidden]="!deleteBtn.loading"
@ -514,4 +523,4 @@
</div> </div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -3,19 +3,19 @@
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise"> <form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title" id="deleteSelectedTitle"> <h2 class="modal-title" id="deleteSelectedTitle">
{{'deleteSelected' | i18n}} {{(permanent ? 'permanentlyDeleteSelected' : 'deleteSelected') | i18n}}
</h2> </h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}"> <button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{'deleteSelectedItemsDesc' | i18n: cipherIds.length}} {{permanent ? 'permanentlyDeleteSelectedItemsDesc' : 'deleteSelectedItemsDesc' | i18n: cipherIds.length}}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button appAutoFocus type="submit" class="btn btn-danger btn-submit" [disabled]="form.loading"> <button appAutoFocus type="submit" class="btn btn-danger btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i> <i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'delete' | i18n}}</span> <span>{{(permanent ? 'permanentlyDelete' : 'delete') | i18n}}</span>
</button> </button>
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button> data-dismiss="modal">{{'cancel' | i18n}}</button>

View File

@ -17,6 +17,7 @@ import { I18nService } from 'jslib/abstractions/i18n.service';
}) })
export class BulkDeleteComponent { export class BulkDeleteComponent {
@Input() cipherIds: string[] = []; @Input() cipherIds: string[] = [];
@Input() permanent: boolean = false;
@Output() onDeleted = new EventEmitter(); @Output() onDeleted = new EventEmitter();
formPromise: Promise<any>; formPromise: Promise<any>;
@ -25,10 +26,12 @@ export class BulkDeleteComponent {
private toasterService: ToasterService, private i18nService: I18nService) { } private toasterService: ToasterService, private i18nService: I18nService) { }
async submit() { async submit() {
this.formPromise = this.cipherService.deleteManyWithServer(this.cipherIds); this.formPromise = this.permanent ? this.cipherService.deleteManyWithServer(this.cipherIds) :
this.cipherService.softDeleteManyWithServer(this.cipherIds);
await this.formPromise; await this.formPromise;
this.onDeleted.emit(); this.onDeleted.emit();
this.analytics.eventTrack.next({ action: 'Bulk Deleted Items' }); this.analytics.eventTrack.next({ action: 'Bulk Deleted Items' });
this.toasterService.popAsync('success', null, this.i18nService.t('deletedItems')); this.toasterService.popAsync('success', null, this.i18nService.t(this.permanent ? 'permanentlyDeletedItems'
: 'deletedItems'));
} }
} }

View File

@ -0,0 +1,25 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="restoreSelectedTitle">
<div class="modal-dialog modal-sm" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h2 class="modal-title" id="restoreSelectedTitle">
{{'restoreSelected' | i18n}}
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{{'restoreSelectedItemsDesc' | i18n: cipherIds.length}}
</div>
<div class="modal-footer">
<button appAutoFocus type="submit" class="btn btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'restore' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,34 @@
import {
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { Angulartics2 } from 'angulartics2';
import { CipherService } from 'jslib/abstractions/cipher.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
@Component({
selector: 'app-vault-bulk-restore',
templateUrl: 'bulk-restore.component.html',
})
export class BulkRestoreComponent {
@Input() cipherIds: string[] = [];
@Output() onRestored = new EventEmitter();
formPromise: Promise<any>;
constructor(private analytics: Angulartics2, private cipherService: CipherService,
private toasterService: ToasterService, private i18nService: I18nService) { }
async submit() {
this.formPromise = this.cipherService.restoreManyWithServer(this.cipherIds);
await this.formPromise;
this.onRestored.emit();
this.analytics.eventTrack.next({ action: 'Bulk Restored Items' });
this.toasterService.popAsync('success', null, this.i18nService.t('restoredItems'));
}
}

View File

@ -12,7 +12,7 @@
<td (click)="checkCipher(c)" class="reduced-lh wrap"> <td (click)="checkCipher(c)" class="reduced-lh wrap">
<a href="#" appStopClick appStopProp (click)="selectCipher(c)" <a href="#" appStopClick appStopProp (click)="selectCipher(c)"
title="{{'editItem' | i18n}}">{{c.name}}</a> title="{{'editItem' | i18n}}">{{c.name}}</a>
<ng-container *ngIf="!organization && c.organizationId"> <ng-container *ngIf="!organization && c.organizationId && !c.isDeleted">
<i class="fa fa-share-alt" appStopProp title="{{'shared' | i18n}}" aria-hidden="true"></i> <i class="fa fa-share-alt" appStopProp title="{{'shared' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'shared' | i18n}}</span> <span class="sr-only">{{'shared' | i18n}}</span>
</ng-container> </ng-container>
@ -36,7 +36,7 @@
<i class="fa fa-cog fa-lg" aria-hidden="true"></i> <i class="fa fa-cog fa-lg" aria-hidden="true"></i>
</button> </button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton"> <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<ng-container *ngIf="c.type === cipherType.Login"> <ng-container *ngIf="c.type === cipherType.Login && !c.isDeleted">
<a class="dropdown-item" href="#" appStopClick <a class="dropdown-item" href="#" appStopClick
(click)="copy(c, c.login.password, 'password', 'password')"> (click)="copy(c, c.login.password, 'password', 'password')">
<i class="fa fa-fw fa-clipboard" aria-hidden="true"></i> <i class="fa fa-fw fa-clipboard" aria-hidden="true"></i>
@ -53,16 +53,18 @@
{{'attachments' | i18n}} {{'attachments' | i18n}}
</a> </a>
<a class="dropdown-item" href="#" appStopClick <a class="dropdown-item" href="#" appStopClick
*ngIf="(!organization && !c.organizationId) || organization" (click)="clone(c)"> *ngIf="((!organization && !c.organizationId) || organization) && !c.isDeleted"
(click)="clone(c)">
<i class="fa fa-fw fa-clone" aria-hidden="true"></i> <i class="fa fa-fw fa-clone" aria-hidden="true"></i>
{{'clone' | i18n}} {{'clone' | i18n}}
</a> </a>
<a class="dropdown-item" href="#" appStopClick *ngIf="!organization && !c.organizationId" <a class="dropdown-item" href="#" appStopClick
*ngIf="!organization && !c.organizationId && !c.isDeleted"
(click)="share(c)"> (click)="share(c)">
<i class="fa fa-fw fa-share-alt" aria-hidden="true"></i> <i class="fa fa-fw fa-share-alt" aria-hidden="true"></i>
{{'share' | i18n}} {{'share' | i18n}}
</a> </a>
<a class="dropdown-item" href="#" appStopClick *ngIf="c.organizationId" <a class="dropdown-item" href="#" appStopClick *ngIf="c.organizationId && !c.isDeleted"
(click)="collections(c)"> (click)="collections(c)">
<i class="fa fa-fw fa-cubes" aria-hidden="true"></i> <i class="fa fa-fw fa-cubes" aria-hidden="true"></i>
{{'collections' | i18n}} {{'collections' | i18n}}
@ -72,9 +74,13 @@
<i class="fa fa-fw fa-file-text-o" aria-hidden="true"></i> <i class="fa fa-fw fa-file-text-o" aria-hidden="true"></i>
{{'eventLogs' | i18n}} {{'eventLogs' | i18n}}
</a> </a>
<a class="dropdown-item" href="#" appStopClick (click)="restore(c)" *ngIf="c.isDeleted">
<i class="fa fa-fw fa-undo" aria-hidden="true"></i>
{{'restore' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(c)"> <a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(c)">
<i class="fa fa-fw fa-trash-o" aria-hidden="true"></i> <i class="fa fa-fw fa-trash-o" aria-hidden="true"></i>
{{'delete' | i18n}} {{(c.isDeleted ? 'permanentlyDelete' : 'delete') | i18n}}
</a> </a>
</div> </div>
</div> </div>
@ -93,4 +99,4 @@
<i class="fa fa-plus fa-fw"></i>{{'addItem' | i18n}}</button> <i class="fa fa-plus fa-fw"></i>{{'addItem' | i18n}}</button>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>

View File

@ -100,18 +100,43 @@ export class CiphersComponent extends BaseCiphersComponent implements OnDestroy
if (this.actionPromise != null) { if (this.actionPromise != null) {
return; return;
} }
const permanent = c.isDeleted;
const confirmed = await this.platformUtilsService.showDialog( 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'); this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) { if (!confirmed) {
return false; return false;
} }
try { try {
this.actionPromise = this.deleteCipher(c.id); this.actionPromise = this.deleteCipher(c.id, permanent);
await this.actionPromise; await this.actionPromise;
this.analytics.eventTrack.next({ action: 'Deleted Cipher' }); 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<boolean> {
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(); this.refresh();
} catch { } } catch { }
this.actionPromise = null; this.actionPromise = null;
@ -134,8 +159,8 @@ export class CiphersComponent extends BaseCiphersComponent implements OnDestroy
} }
} }
protected deleteCipher(id: string) { protected deleteCipher(id: string, permanent: boolean) {
return this.cipherService.deleteWithServer(id); return permanent ? this.cipherService.deleteWithServer(id) : this.cipherService.softDeleteWithServer(id);
} }
protected showFixOldAttachments(c: CipherView) { protected showFixOldAttachments(c: CipherView) {

View File

@ -20,6 +20,11 @@
<i class="fa-li fa fa-fw fa-star"></i>{{'favorites' | i18n}} <i class="fa-li fa fa-fw fa-star"></i>{{'favorites' | i18n}}
</a> </a>
</li> </li>
<li [ngClass]="{active: selectedTrash}" *ngIf="showTrash">
<a href="#" appStopClick (click)="selectTrash()">
<i class="fa-li fa fa-fw fa-trash-o"></i>{{'trash' | i18n}}
</a>
</li>
</ul> </ul>
<h3>{{'types' | i18n}}</h3> <h3>{{'types' | i18n}}</h3>
<ul class="fa-ul card-ul"> <ul class="fa-ul card-ul">

View File

@ -4,7 +4,8 @@
<app-vault-groupings (onAllClicked)="clearGroupingFilters()" (onFavoritesClicked)="filterFavorites()" <app-vault-groupings (onAllClicked)="clearGroupingFilters()" (onFavoritesClicked)="filterFavorites()"
(onCipherTypeClicked)="filterCipherType($event)" (onFolderClicked)="filterFolder($event.id)" (onCipherTypeClicked)="filterCipherType($event)" (onFolderClicked)="filterFolder($event.id)"
(onAddFolder)="addFolder()" (onEditFolder)="editFolder($event.id)" (onAddFolder)="addFolder()" (onEditFolder)="editFolder($event.id)"
(onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)"> (onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)"
(onTrashClicked)="filterDeleted()">
</app-vault-groupings> </app-vault-groupings>
</div> </div>
<div class="col-6"> <div class="col-6">
@ -27,17 +28,21 @@
<i class="fa fa-cog" aria-hidden="true"></i> <i class="fa fa-cog" aria-hidden="true"></i>
</button> </button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="bulkActionsButton"> <div class="dropdown-menu dropdown-menu-right" aria-labelledby="bulkActionsButton">
<a class="dropdown-item" href="#" appStopClick (click)="bulkMove()"> <a class="dropdown-item" href="#" appStopClick (click)="bulkMove()" *ngIf="!deleted">
<i class="fa fa-fw fa-share" aria-hidden="true"></i> <i class="fa fa-fw fa-share" aria-hidden="true"></i>
{{'moveSelected' | i18n}} {{'moveSelected' | i18n}}
</a> </a>
<a class="dropdown-item" href="#" appStopClick (click)="bulkShare()"> <a class="dropdown-item" href="#" appStopClick (click)="bulkShare()" *ngIf="!deleted">
<i class="fa fa-fw fa-share-alt" aria-hidden="true"></i> <i class="fa fa-fw fa-share-alt" aria-hidden="true"></i>
{{'shareSelected' | i18n}} {{'shareSelected' | i18n}}
</a> </a>
<a class="dropdown-item" href="#" (click)="bulkRestore()" *ngIf="deleted">
<i class="fa fa-fw fa-undo" aria-hidden="true"></i>
{{'restoreSelected' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" (click)="bulkDelete()"> <a class="dropdown-item text-danger" href="#" (click)="bulkDelete()">
<i class="fa fa-fw fa-trash-o" aria-hidden="true"></i> <i class="fa fa-fw fa-trash-o" aria-hidden="true"></i>
{{'deleteSelected' | i18n}} {{(deleted ? 'permanentlyDeleteSelected' : 'deleteSelected') | i18n}}
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" appStopClick (click)="selectAll(true)"> <a class="dropdown-item" href="#" appStopClick (click)="selectAll(true)">
@ -50,7 +55,7 @@
</a> </a>
</div> </div>
</div> </div>
<button type="button" class="btn btn-outline-primary btn-sm" (click)="addCipher()"> <button type="button" class="btn btn-outline-primary btn-sm" (click)="addCipher()" *ngIf="!deleted">
<i class="fa fa-plus fa-fw" aria-hidden="true"></i>{{'addItem' | i18n}} <i class="fa fa-plus fa-fw" aria-hidden="true"></i>{{'addItem' | i18n}}
</button> </button>
</div> </div>
@ -118,6 +123,7 @@
<ng-template #share></ng-template> <ng-template #share></ng-template>
<ng-template #collections></ng-template> <ng-template #collections></ng-template>
<ng-template #bulkDeleteTemplate></ng-template> <ng-template #bulkDeleteTemplate></ng-template>
<ng-template #bulkRestoreTemplate></ng-template>
<ng-template #bulkMoveTemplate></ng-template> <ng-template #bulkMoveTemplate></ng-template>
<ng-template #bulkShareTemplate></ng-template> <ng-template #bulkShareTemplate></ng-template>
<ng-template #updateKeyTemplate></ng-template> <ng-template #updateKeyTemplate></ng-template>

View File

@ -27,6 +27,7 @@ import { AddEditComponent } from './add-edit.component';
import { AttachmentsComponent } from './attachments.component'; import { AttachmentsComponent } from './attachments.component';
import { BulkDeleteComponent } from './bulk-delete.component'; import { BulkDeleteComponent } from './bulk-delete.component';
import { BulkMoveComponent } from './bulk-move.component'; import { BulkMoveComponent } from './bulk-move.component';
import { BulkRestoreComponent } from './bulk-restore.component';
import { BulkShareComponent } from './bulk-share.component'; import { BulkShareComponent } from './bulk-share.component';
import { CiphersComponent } from './ciphers.component'; import { CiphersComponent } from './ciphers.component';
import { CollectionsComponent } from './collections.component'; import { CollectionsComponent } from './collections.component';
@ -60,6 +61,7 @@ export class VaultComponent implements OnInit, OnDestroy {
@ViewChild('share', { read: ViewContainerRef }) shareModalRef: ViewContainerRef; @ViewChild('share', { read: ViewContainerRef }) shareModalRef: ViewContainerRef;
@ViewChild('collections', { read: ViewContainerRef }) collectionsModalRef: ViewContainerRef; @ViewChild('collections', { read: ViewContainerRef }) collectionsModalRef: ViewContainerRef;
@ViewChild('bulkDeleteTemplate', { read: ViewContainerRef }) bulkDeleteModalRef: ViewContainerRef; @ViewChild('bulkDeleteTemplate', { read: ViewContainerRef }) bulkDeleteModalRef: ViewContainerRef;
@ViewChild('bulkRestoreTemplate', { read: ViewContainerRef }) bulkRestoreModalRef: ViewContainerRef;
@ViewChild('bulkMoveTemplate', { read: ViewContainerRef }) bulkMoveModalRef: ViewContainerRef; @ViewChild('bulkMoveTemplate', { read: ViewContainerRef }) bulkMoveModalRef: ViewContainerRef;
@ViewChild('bulkShareTemplate', { read: ViewContainerRef }) bulkShareModalRef: ViewContainerRef; @ViewChild('bulkShareTemplate', { read: ViewContainerRef }) bulkShareModalRef: ViewContainerRef;
@ViewChild('updateKeyTemplate', { read: ViewContainerRef }) updateKeyModalRef: ViewContainerRef; @ViewChild('updateKeyTemplate', { read: ViewContainerRef }) updateKeyModalRef: ViewContainerRef;
@ -72,6 +74,7 @@ export class VaultComponent implements OnInit, OnDestroy {
showBrowserOutdated = false; showBrowserOutdated = false;
showUpdateKey = false; showUpdateKey = false;
showPremiumCallout = false; showPremiumCallout = false;
deleted: boolean = false;
private modal: ModalComponent = null; private modal: ModalComponent = null;
@ -104,7 +107,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.groupingsComponent.selectedAll = true; this.groupingsComponent.selectedAll = true;
await this.ciphersComponent.reload(); await this.ciphersComponent.reload();
} else { } else {
if (params.favorites) { if (params.deleted) {
this.groupingsComponent.selectedTrash = true;
await this.filterDeleted();
} else if (params.favorites) {
this.groupingsComponent.selectedFavorites = true; this.groupingsComponent.selectedFavorites = true;
await this.filterFavorites(); await this.filterFavorites();
} else if (params.type) { } else if (params.type) {
@ -168,6 +174,16 @@ export class VaultComponent implements OnInit, OnDestroy {
this.go(); 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) { async filterCipherType(type: CipherType) {
this.ciphersComponent.showAddNew = true; this.ciphersComponent.showAddNew = true;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType'); this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType');
@ -358,6 +374,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.modal.close(); this.modal.close();
await this.ciphersComponent.refresh(); await this.ciphersComponent.refresh();
}); });
childComponent.onRestoredCipher.subscribe(async (c: CipherView) => {
this.modal.close();
await this.ciphersComponent.refresh();
});
this.modal.onClosed.subscribe(() => { this.modal.onClosed.subscribe(() => {
this.modal = null; this.modal = null;
@ -387,6 +407,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.modal = this.bulkDeleteModalRef.createComponent(factory).instance; this.modal = this.bulkDeleteModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<BulkDeleteComponent>(BulkDeleteComponent, this.bulkDeleteModalRef); const childComponent = this.modal.show<BulkDeleteComponent>(BulkDeleteComponent, this.bulkDeleteModalRef);
childComponent.permanent = this.deleted;
childComponent.cipherIds = selectedIds; childComponent.cipherIds = selectedIds;
childComponent.onDeleted.subscribe(async () => { childComponent.onDeleted.subscribe(async () => {
this.modal.close(); 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>(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() { bulkShare() {
const selectedCiphers = this.ciphersComponent.getSelected(); const selectedCiphers = this.ciphersComponent.getSelected();
if (selectedCiphers.length === 0) { if (selectedCiphers.length === 0) {
@ -475,6 +523,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.collectionId = null; this.collectionId = null;
this.favorites = false; this.favorites = false;
this.type = null; this.type = null;
this.deleted = false;
} }
private go(queryParams: any = null) { private go(queryParams: any = null) {
@ -484,6 +533,7 @@ export class VaultComponent implements OnInit, OnDestroy {
type: this.type, type: this.type,
folderId: this.folderId, folderId: this.folderId,
collectionId: this.collectionId, collectionId: this.collectionId,
deleted: this.deleted ? true : null,
}; };
} }

View File

@ -461,10 +461,10 @@
"message": "Are you sure you want to delete this item?" "message": "Are you sure you want to delete this item?"
}, },
"deletedItem": { "deletedItem": {
"message": "Deleted item" "message": "Item sent to trash"
}, },
"deletedItems": { "deletedItems": {
"message": "Deleted items" "message": "Items sent to trash"
}, },
"movedItems": { "movedItems": {
"message": "Moved items" "message": "Moved items"
@ -2249,7 +2249,7 @@
} }
}, },
"deletedItemId": { "deletedItemId": {
"message": "Deleted item $ID$.", "message": "Sent item $ID$ to trash.",
"placeholders": { "placeholders": {
"id": { "id": {
"content": "$1", "content": "$1",
@ -3054,6 +3054,88 @@
"message": "Lock", "message": "Lock",
"description": "Verb form: to make secure or inaccesible by" "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"
}
}
},
"vaultTimeoutLogOutConfirmation": { "vaultTimeoutLogOutConfirmation": {
"message": "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?" "message": "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?"
}, },