1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-25 16:59:17 +01:00

Merge pull request #511 from bitwarden/soft-delete-chad

[Soft Delete] - Added trash and related functionality to web vault
This commit is contained in:
Chad Scharf 2020-04-10 13:56:33 -04:00 committed by GitHub
commit 9f1b8ae58f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 391 additions and 93 deletions

2
jslib

@ -1 +1 @@
Subproject commit 72e3893f8eee79f1e3678839aa194f1096c343ea
Subproject commit 3a10c1ff3027832953094b2f7edddb2361119b09

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
<div class="container page-content">
<div class="row">
<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)"
(onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)">
(onCollectionClicked)="filterCollection($event.id)" (onSearchTextChanged)="filterSearchText($event)"
(onTrashClicked)="filterDeleted()">
</app-org-vault-groupings>
</div>
<div class="col-9">
@ -18,7 +19,8 @@
</ng-container>
</small>
</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}}
</button>
</div>
@ -33,4 +35,4 @@
<ng-template #attachments></ng-template>
<ng-template #cipherAddEdit></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;
organization: Organization;
collectionId: string;
type: CipherType;
collectionId: string = null;
type: CipherType = null;
deleted: boolean = false;
private modal: ModalComponent = null;
@ -61,7 +62,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private broadcasterService: BroadcasterService, private ngZone: NgZone) { }
ngOnInit() {
this.route.parent.params.subscribe(async (params) => {
const queryParams = this.route.parent.params.subscribe(async (params) => {
this.organization = await this.userService.getOrganization(params.organizationId);
this.groupingsComponent.organization = this.organization;
this.ciphersComponent.organization = this.organization;
@ -92,7 +93,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.groupingsComponent.selectedAll = true;
await this.ciphersComponent.reload();
} else {
if (qParams.type) {
if (qParams.deleted) {
this.groupingsComponent.selectedTrash = true;
await this.filterDeleted(true);
} else if (qParams.type) {
const t = parseInt(qParams.type, null);
this.groupingsComponent.selectedType = t;
await this.filterCipherType(t, true);
@ -116,6 +120,10 @@ export class VaultComponent implements OnInit, OnDestroy {
queryParamsSub.unsubscribe();
}
});
if (queryParams != null) {
queryParams.unsubscribe();
}
});
}
@ -125,6 +133,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async clearGroupingFilters() {
this.ciphersComponent.showAddNew = true;
this.ciphersComponent.deleted = false;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchVault');
await this.ciphersComponent.applyFilter();
this.clearFilters();
@ -133,6 +142,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async filterCipherType(type: CipherType, load = false) {
this.ciphersComponent.showAddNew = true;
this.ciphersComponent.deleted = false;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType');
const filter = (c: CipherView) => c.type === type;
if (load) {
@ -147,6 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async filterCollection(collectionId: string, load = false) {
this.ciphersComponent.showAddNew = true;
this.ciphersComponent.deleted = false;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchCollection');
const filter = (c: CipherView) => {
if (collectionId === 'unassigned') {
@ -165,6 +176,20 @@ export class VaultComponent implements OnInit, OnDestroy {
this.go();
}
async filterDeleted(load: boolean = false) {
this.ciphersComponent.showAddNew = false;
this.ciphersComponent.deleted = true;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchTrash');
if (load) {
await this.ciphersComponent.reload(null, true);
} else {
await this.ciphersComponent.applyFilter(null);
}
this.clearFilters();
this.deleted = true;
this.go();
}
filterSearchText(searchText: string) {
this.ciphersComponent.searchText = searchText;
this.ciphersComponent.search(200);
@ -255,6 +280,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.modal.close();
await this.ciphersComponent.refresh();
});
childComponent.onRestoredCipher.subscribe(async (c: CipherView) => {
this.modal.close();
await this.ciphersComponent.refresh();
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
@ -299,6 +328,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private clearFilters() {
this.collectionId = null;
this.type = null;
this.deleted = false;
}
private go(queryParams: any = null) {
@ -306,6 +336,7 @@ export class VaultComponent implements OnInit, OnDestroy {
queryParams = {
type: this.type,
collectionId: this.collectionId,
deleted: this.deleted ? true : null,
};
}

View File

@ -73,8 +73,14 @@ export class EventService {
msg = this.i18nService.t('editedItemId', this.formatCipherId(ev, options));
break;
case EventType.Cipher_Deleted:
msg = this.i18nService.t('permanentlyDeletedItemId', this.formatCipherId(ev, options));
break;
case EventType.Cipher_SoftDeleted:
msg = this.i18nService.t('deletedItemId', this.formatCipherId(ev, options));
break;
case EventType.Cipher_Restored:
msg = this.i18nService.t('restoredItemId', this.formatCipherId(ev, options));
break;
case EventType.Cipher_AttachmentCreated:
msg = this.i18nService.t('createdAttachmentForItem', this.formatCipherId(ev, options));
break;

View File

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

View File

@ -12,7 +12,8 @@
<div class="row" *ngIf="!editMode">
<div class="col-6 form-group">
<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>
</select>
</div>
@ -21,11 +22,12 @@
<div class="col-6 form-group">
<label for="name">{{'name' | i18n}}</label>
<input id="name" class="form-control" type="text" name="Name" [(ngModel)]="cipher.name"
required>
required [disabled]="cipher.isDeleted">
</div>
<div class="col-6 form-group" *ngIf="!organization">
<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>
</select>
</div>
@ -37,8 +39,8 @@
<label for="loginUsername">{{'username' | i18n}}</label>
<div class="input-group">
<input id="loginUsername" class="form-control" type="text" name="Login.Username"
[(ngModel)]="cipher.login.username" appInputVerbatim>
<div class="input-group-append">
[(ngModel)]="cipher.login.username" appInputVerbatim [disabled]="cipher.isDeleted">
<div class="input-group-append" *ngIf="!cipher.isDeleted">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyUsername' | i18n}}"
(click)="copy(cipher.login.username, 'username', 'Username')" tabindex="-1">
@ -50,7 +52,7 @@
<div class="col-6 form-group">
<div class="d-flex">
<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
appA11yTitle="{{'generatePassword' | i18n}}" (click)="generatePassword()">
<i class="fa fa-lg fa-fw fa-refresh" aria-hidden="true"></i>
@ -68,7 +70,8 @@
<div class="input-group">
<input id="loginPassword" class="form-control text-monospace"
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">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="togglePassword()"
@ -89,7 +92,7 @@
<div class="col-6 form-group">
<label for="loginTotp">{{'authenticatorKeyTotp' | i18n}}</label>
<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 class="col-6 form-group totp d-flex align-items-end" [ngClass]="{'low': totpLow}">
<div *ngIf="!cipher.login.totp || !totpCode">
@ -132,7 +135,7 @@
<label for="loginUri{{i}}">{{'uriPosition' | i18n : (i + 1)}}</label>
<div class="input-group">
<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>
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
@ -160,19 +163,20 @@
</div>
<div class="d-flex">
<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>
</select>
<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>
</button>
</div>
</div>
</div>
</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}}
</a>
</ng-container>
@ -182,12 +186,13 @@
<div class="col-6 form-group">
<label for="cardCardholderName">{{'cardholderName' | i18n}}</label>
<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 class="col-6 form-group">
<label for="cardBrand">{{'brand' | i18n}}</label>
<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>
</select>
</div>
@ -197,7 +202,7 @@
<label for="cardNumber">{{'number' | i18n}}</label>
<div class="input-group">
<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">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyNumber' | i18n}}"
@ -210,14 +215,15 @@
<div class="col form-group">
<label for="cardExpMonth">{{'expirationMonth' | i18n}}</label>
<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>
</select>
</div>
<div class="col form-group">
<label for="cardExpYear">{{'expirationYear' | i18n}}</label>
<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 class="row">
@ -226,7 +232,8 @@
<div class="input-group">
<input id="cardCode" class="form-control text-monospace"
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">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleCardCode()"
@ -250,7 +257,7 @@
<div class="col-4 form-group">
<label for="idTitle">{{'title' | i18n}}</label>
<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>
</select>
</div>
@ -259,107 +266,107 @@
<div class="col-4 form-group">
<label for="idFirstName">{{'firstName' | i18n}}</label>
<input id="idFirstName" class="form-control" type="text" name="Identity.FirstName"
[(ngModel)]="cipher.identity.firstName">
[(ngModel)]="cipher.identity.firstName" [disabled]="cipher.isDeleted">
</div>
<div class="col-4 form-group">
<label for="idMiddleName">{{'middleName' | i18n}}</label>
<input id="idMiddleName" class="form-control" type="text" name="Identity.MiddleName"
[(ngModel)]="cipher.identity.middleName">
[(ngModel)]="cipher.identity.middleName" [disabled]="cipher.isDeleted">
</div>
<div class="col-4 form-group">
<label for="idLastName">{{'lastName' | i18n}}</label>
<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 class="row">
<div class="col-4 form-group">
<label for="idUsername">{{'username' | i18n}}</label>
<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 class="col-4 form-group">
<label for="idCompany">{{'company' | i18n}}</label>
<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 class="row">
<div class="col-4 form-group">
<label for="idSsn">{{'ssn' | i18n}}</label>
<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 class="col-4 form-group">
<label for="idPassportNumber">{{'passportNumber' | i18n}}</label>
<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 class="col-4 form-group">
<label for="idLicenseNumber">{{'licenseNumber' | i18n}}</label>
<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 class="row">
<div class="col-6 form-group">
<label for="idEmail">{{'email' | i18n}}</label>
<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 class="col-6 form-group">
<label for="idPhone">{{'phone' | i18n}}</label>
<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 class="row">
<div class="col-6 form-group">
<label for="idAddress1">{{'address1' | i18n}}</label>
<input id="idAddress1" class="form-control" type="text" name="Identity.Address1"
[(ngModel)]="cipher.identity.address1">
[(ngModel)]="cipher.identity.address1" [disabled]="cipher.isDeleted">
</div>
<div class="col-6 form-group">
<label for="idAddress2">{{'address2' | i18n}}</label>
<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 class="row">
<div class="col-6 form-group">
<label for="idAddress3">{{'address3' | i18n}}</label>
<input id="idAddress3" class="form-control" type="text" name="Identity.Address3"
[(ngModel)]="cipher.identity.address3">
[(ngModel)]="cipher.identity.address3" [disabled]="cipher.isDeleted">
</div>
<div class="col-6 form-group">
<label for="idCity">{{'cityTown' | i18n}}</label>
<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 class="row">
<div class="col-6 form-group">
<label for="idState">{{'stateProvince' | i18n}}</label>
<input id="idState" class="form-control" type="text" name="Identity.State"
[(ngModel)]="cipher.identity.state">
[(ngModel)]="cipher.identity.state" [disabled]="cipher.isDeleted">
</div>
<div class="col-6 form-group">
<label for="idPostalCode">{{'zipPostalCode' | i18n}}</label>
<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 class="row">
<div class="col-6 form-group">
<label for="idCountry">{{'country' | i18n}}</label>
<input id="idCountry" class="form-control" type="text" name="Identity.Country"
[(ngModel)]="cipher.identity.country">
[(ngModel)]="cipher.identity.country" [disabled]="cipher.isDeleted">
</div>
</div>
</ng-container>
<div class="form-group">
<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>
</div>
<h3 class="mt-4">{{'customFields' | i18n}}</h3>
@ -374,14 +381,14 @@
</a>
</div>
<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 class="col-7 form-group">
<label for="fieldValue{{i}}">{{'value' | i18n}}</label>
<div class="d-flex align-items-center">
<div class="input-group" *ngIf="f.type === fieldType.Text">
<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">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyValue' | i18n}}"
@ -394,7 +401,7 @@
<input id="fieldValue{{i}}" type="{{f.showValue ? 'text' : 'password'}}"
name="Field.Value{{i}}" [(ngModel)]="f.value"
class="form-control text-monospace" appInputVerbatim
autocomplete="new-password">
autocomplete="new-password" [disabled]="cipher.isDeleted">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleFieldValue(f)"
@ -414,24 +421,24 @@
<div class="flex-fill">
<input id="fieldValue{{i}}" name="Field.Value{{i}}" type="checkbox"
[(ngModel)]="f.value" *ngIf="f.type === fieldType.Boolean" appTrueFalseValue
trueValue="true" falseValue="false">
trueValue="true" falseValue="false" [disabled]="cipher.isDeleted">
</div>
<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>
</button>
<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>
</button>
</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}}
</a>
<div class="row">
<div class="row" *ngIf="!cipher.isDeleted">
<div class="col-5">
<label for="addFieldType" class="sr-only">{{'type' | i18n}}</label>
<select id="addFieldType" class="form-control" name="AddFieldType" [(ngModel)]="addFieldType">
@ -445,7 +452,8 @@
<div class="col-5">
<label for="organizationId">{{'whoOwnsThisItem' | i18n}}</label>
<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>
</select>
</div>
@ -459,7 +467,7 @@
<ng-container *ngIf="collections && collections.length">
<div class="form-check" *ngFor="let c of collections; let i = index">
<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>
</div>
</ng-container>
@ -492,19 +500,20 @@
<div class="modal-footer">
<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>
<span>{{'save' | i18n}}</span>
<span>{{(cipher.isDeleted ? 'restore' : 'save') | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{'cancel' | i18n}}
</button>
<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}}">
<i class="fa fa-lg" [ngClass]="{'fa-star': cipher.favorite, 'fa-star-o': !cipher.favorite}"
aria-hidden="true"></i>
</button>
<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">
<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"
@ -514,4 +523,4 @@
</div>
</form>
</div>
</div>
</div>

View File

@ -3,19 +3,19 @@
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h2 class="modal-title" id="deleteSelectedTitle">
{{'deleteSelected' | i18n}}
{{(permanent ? 'permanentlyDeleteSelected' : 'deleteSelected') | 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">
{{'deleteSelectedItemsDesc' | i18n: cipherIds.length}}
{{permanent ? 'permanentlyDeleteSelectedItemsDesc' : 'deleteSelectedItemsDesc' | i18n: cipherIds.length}}
</div>
<div class="modal-footer">
<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>
<span>{{'delete' | i18n}}</span>
<span>{{(permanent ? 'permanentlyDelete' : 'delete') | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button>

View File

@ -17,6 +17,7 @@ import { I18nService } from 'jslib/abstractions/i18n.service';
})
export class BulkDeleteComponent {
@Input() cipherIds: string[] = [];
@Input() permanent: boolean = false;
@Output() onDeleted = new EventEmitter();
formPromise: Promise<any>;
@ -25,10 +26,12 @@ export class BulkDeleteComponent {
private toasterService: ToasterService, private i18nService: I18nService) { }
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;
this.onDeleted.emit();
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">
<a href="#" appStopClick appStopProp (click)="selectCipher(c)"
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>
<span class="sr-only">{{'shared' | i18n}}</span>
</ng-container>
@ -36,7 +36,7 @@
<i class="fa fa-cog fa-lg" aria-hidden="true"></i>
</button>
<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
(click)="copy(c, c.login.password, 'password', 'password')">
<i class="fa fa-fw fa-clipboard" aria-hidden="true"></i>
@ -53,16 +53,18 @@
{{'attachments' | i18n}}
</a>
<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>
{{'clone' | i18n}}
</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)">
<i class="fa fa-fw fa-share-alt" aria-hidden="true"></i>
{{'share' | i18n}}
</a>
<a class="dropdown-item" href="#" appStopClick *ngIf="c.organizationId"
<a class="dropdown-item" href="#" appStopClick *ngIf="c.organizationId && !c.isDeleted"
(click)="collections(c)">
<i class="fa fa-fw fa-cubes" aria-hidden="true"></i>
{{'collections' | i18n}}
@ -72,9 +74,13 @@
<i class="fa fa-fw fa-file-text-o" aria-hidden="true"></i>
{{'eventLogs' | i18n}}
</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)">
<i class="fa fa-fw fa-trash-o" aria-hidden="true"></i>
{{'delete' | i18n}}
{{(c.isDeleted ? 'permanentlyDelete' : 'delete') | i18n}}
</a>
</div>
</div>
@ -93,4 +99,4 @@
<i class="fa fa-plus fa-fw"></i>{{'addItem' | i18n}}</button>
</ng-container>
</div>
</ng-container>
</ng-container>

View File

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

View File

@ -20,6 +20,11 @@
<i class="fa-li fa fa-fw fa-star"></i>{{'favorites' | i18n}}
</a>
</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>
<h3>{{'types' | i18n}}</h3>
<ul class="fa-ul card-ul">

View File

@ -4,7 +4,8 @@
<app-vault-groupings (onAllClicked)="clearGroupingFilters()" (onFavoritesClicked)="filterFavorites()"
(onCipherTypeClicked)="filterCipherType($event)" (onFolderClicked)="filterFolder($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>
</div>
<div class="col-6">
@ -27,17 +28,21 @@
<i class="fa fa-cog" aria-hidden="true"></i>
</button>
<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>
{{'moveSelected' | i18n}}
</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>
{{'shareSelected' | i18n}}
</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()">
<i class="fa fa-fw fa-trash-o" aria-hidden="true"></i>
{{'deleteSelected' | i18n}}
{{(deleted ? 'permanentlyDeleteSelected' : 'deleteSelected') | i18n}}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" appStopClick (click)="selectAll(true)">
@ -50,7 +55,7 @@
</a>
</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}}
</button>
</div>
@ -118,6 +123,7 @@
<ng-template #share></ng-template>
<ng-template #collections></ng-template>
<ng-template #bulkDeleteTemplate></ng-template>
<ng-template #bulkRestoreTemplate></ng-template>
<ng-template #bulkMoveTemplate></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 { BulkDeleteComponent } from './bulk-delete.component';
import { BulkMoveComponent } from './bulk-move.component';
import { BulkRestoreComponent } from './bulk-restore.component';
import { BulkShareComponent } from './bulk-share.component';
import { CiphersComponent } from './ciphers.component';
import { CollectionsComponent } from './collections.component';
@ -60,6 +61,7 @@ export class VaultComponent implements OnInit, OnDestroy {
@ViewChild('share', { read: ViewContainerRef }) shareModalRef: ViewContainerRef;
@ViewChild('collections', { read: ViewContainerRef }) collectionsModalRef: ViewContainerRef;
@ViewChild('bulkDeleteTemplate', { read: ViewContainerRef }) bulkDeleteModalRef: ViewContainerRef;
@ViewChild('bulkRestoreTemplate', { read: ViewContainerRef }) bulkRestoreModalRef: ViewContainerRef;
@ViewChild('bulkMoveTemplate', { read: ViewContainerRef }) bulkMoveModalRef: ViewContainerRef;
@ViewChild('bulkShareTemplate', { read: ViewContainerRef }) bulkShareModalRef: ViewContainerRef;
@ViewChild('updateKeyTemplate', { read: ViewContainerRef }) updateKeyModalRef: ViewContainerRef;
@ -72,6 +74,7 @@ export class VaultComponent implements OnInit, OnDestroy {
showBrowserOutdated = false;
showUpdateKey = false;
showPremiumCallout = false;
deleted: boolean = false;
private modal: ModalComponent = null;
@ -104,7 +107,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.groupingsComponent.selectedAll = true;
await this.ciphersComponent.reload();
} else {
if (params.favorites) {
if (params.deleted) {
this.groupingsComponent.selectedTrash = true;
await this.filterDeleted();
} else if (params.favorites) {
this.groupingsComponent.selectedFavorites = true;
await this.filterFavorites();
} else if (params.type) {
@ -168,6 +174,16 @@ export class VaultComponent implements OnInit, OnDestroy {
this.go();
}
async filterDeleted() {
this.ciphersComponent.showAddNew = false;
this.ciphersComponent.deleted = true;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchTrash');
await this.ciphersComponent.reload(null, true);
this.clearFilters();
this.deleted = true;
this.go();
}
async filterCipherType(type: CipherType) {
this.ciphersComponent.showAddNew = true;
this.groupingsComponent.searchPlaceholder = this.i18nService.t('searchType');
@ -358,6 +374,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.modal.close();
await this.ciphersComponent.refresh();
});
childComponent.onRestoredCipher.subscribe(async (c: CipherView) => {
this.modal.close();
await this.ciphersComponent.refresh();
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
@ -387,6 +407,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.modal = this.bulkDeleteModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<BulkDeleteComponent>(BulkDeleteComponent, this.bulkDeleteModalRef);
childComponent.permanent = this.deleted;
childComponent.cipherIds = selectedIds;
childComponent.onDeleted.subscribe(async () => {
this.modal.close();
@ -398,6 +419,33 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
bulkRestore() {
const selectedIds = this.ciphersComponent.getSelectedIds();
if (selectedIds.length === 0) {
this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'),
this.i18nService.t('nothingSelected'));
return;
}
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.bulkRestoreModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<BulkRestoreComponent>(BulkRestoreComponent, this.bulkRestoreModalRef);
childComponent.cipherIds = selectedIds;
childComponent.onRestored.subscribe(async () => {
this.modal.close();
await this.ciphersComponent.refresh();
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
bulkShare() {
const selectedCiphers = this.ciphersComponent.getSelected();
if (selectedCiphers.length === 0) {
@ -475,6 +523,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.collectionId = null;
this.favorites = false;
this.type = null;
this.deleted = false;
}
private go(queryParams: any = null) {
@ -484,6 +533,7 @@ export class VaultComponent implements OnInit, OnDestroy {
type: this.type,
folderId: this.folderId,
collectionId: this.collectionId,
deleted: this.deleted ? true : null,
};
}

View File

@ -3053,5 +3053,87 @@
"lock": {
"message": "Lock",
"description": "Verb form: to make secure or inaccesible by"
},
"trash": {
"message": "Trash",
"description": "Noun: A special folder for holding deleted items that have not yet been permanently deleted"
},
"searchTrash": {
"message": "Search Trash"
},
"permanentlyDelete": {
"message": "Permanently Delete"
},
"permanentlyDeleteSelected": {
"message": "Permanently Delete Selected"
},
"permanentlyDeleteItem": {
"message": "Permanently Delete Item"
},
"permanentlyDeleteItemConfirmation": {
"message": "Are you sure you want to permanently delete this item?"
},
"permanentlyDeletedItem": {
"message": "Permanently Deleted item"
},
"permanentlyDeletedItems": {
"message": "Permanently Deleted items"
},
"permanentlyDeleteSelectedItemsDesc": {
"message": "You have selected $COUNT$ item(s) to permanently delete. Are you sure you want to permanently delete all of these items?",
"placeholders": {
"count": {
"content": "$1",
"example": "150"
}
}
},
"permanentlyDeletedItemId": {
"message": "Permanently Deleted item $ID$.",
"placeholders": {
"id": {
"content": "$1",
"example": "Google"
}
}
},
"restore": {
"message": "Restore"
},
"restoreSelected": {
"message": "Restore Selected"
},
"restoreItem": {
"message": "Restore Item"
},
"restoredItem": {
"message": "Restored Item"
},
"restoredItems": {
"message": "Restored Items"
},
"restoreItemConfirmation": {
"message": "Are you sure you want to restore this item?"
},
"restoreItems": {
"message": "Restore items"
},
"restoreSelectedItemsDesc": {
"message": "You have selected $COUNT$ item(s) to restore. Are you sure you want to restore all of these items?",
"placeholders": {
"count": {
"content": "$1",
"example": "150"
}
}
},
"restoredItemId": {
"message": "Restored item $ID$.",
"placeholders": {
"id": {
"content": "$1",
"example": "Google"
}
}
}
}