SM-281: Secrets Manager Trash (#4730)

* SM-281: Initial commit with trash component setup

* SM-281: Customize secrets list component, add ability to hard delete secrets

* SM-281: Add support for restoring secrets in SM

* SM-281: restoreSecret emit values as an array

* SM-281: Fix bug caused by mistake when doing merge conflict resolution

* SM-281: Clean up TrashService and move more functionality to TrashApiService

* Cleanup responses

* Merge TrashService and SecretService

* Remove tw-text-sm from dialogs

* Split delete into two components

* Change secrets table to have a single boolean for trash

* SM-281: Rename component to secret-hard-delete

* Remove unused organizationId

* Remove duplicate buttons

---------

Co-authored-by: Hinton <hinton@users.noreply.github.com>
This commit is contained in:
Colton Hurst 2023-02-21 10:03:37 -05:00 committed by GitHub
parent 208be8dfbf
commit d11f03cb78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 356 additions and 26 deletions

View File

@ -5734,6 +5734,12 @@
"deleteSecrets":{ "deleteSecrets":{
"message": "Delete secrets" "message": "Delete secrets"
}, },
"hardDeleteSecret":{
"message": "Permanently delete secret"
},
"hardDeleteSecrets":{
"message": "Permanently delete secrets"
},
"secretProjectAssociationDescription" :{ "secretProjectAssociationDescription" :{
"message": "Select projects that the secret will be associated with. Only organization users with access to these projects will be able to see the secret." "message": "Select projects that the secret will be associated with. Only organization users with access to these projects will be able to see the secret."
}, },
@ -5788,6 +5794,9 @@
"secretsNoItemsMessage":{ "secretsNoItemsMessage":{
"message": "To get started, add a new secret or import secrets." "message": "To get started, add a new secret or import secrets."
}, },
"secretsTrashNoItemsMessage":{
"message": "There are no secrets in the trash."
},
"serviceAccountsNoItemsTitle":{ "serviceAccountsNoItemsTitle":{
"message":"Nothing to show yet" "message":"Nothing to show yet"
}, },
@ -5833,6 +5842,15 @@
"softDeletesSuccessToast":{ "softDeletesSuccessToast":{
"message":"Secrets sent to trash" "message":"Secrets sent to trash"
}, },
"hardDeleteSecretConfirmation": {
"message": "Are you sure you want to permanently delete this secret?"
},
"hardDeleteSecretsConfirmation": {
"message": "Are you sure you want to permanently delete these secrets?"
},
"hardDeletesSuccessToast":{
"message":"Secrets permanently deleted"
},
"serviceAccountCreated":{ "serviceAccountCreated":{
"message":"Service account created" "message":"Service account created"
}, },
@ -5911,6 +5929,9 @@
"softDeleteSuccessToast":{ "softDeleteSuccessToast":{
"message":"Secret sent to trash" "message":"Secret sent to trash"
}, },
"hardDeleteSuccessToast":{
"message":"Secret permanently deleted"
},
"searchProjects":{ "searchProjects":{
"message":"Search projects" "message":"Search projects"
}, },
@ -6399,6 +6420,24 @@
"errorReadingImportFile": { "errorReadingImportFile": {
"message": "An error occurred when trying to read the import file" "message": "An error occurred when trying to read the import file"
}, },
"restoreSecret": {
"message": "Restore secret"
},
"restoreSecrets": {
"message": "Restore secrets"
},
"restoreSecretPrompt": {
"message": "Are you sure you want to restore this secret?"
},
"restoreSecretsPrompt": {
"message": "Are you sure you want to restore these secrets?"
},
"secretRestoredSuccessToast": {
"message": "Secret restored"
},
"secretsRestoredSuccessToast": {
"message": "Secrets restored"
},
"selectionIsRequired": { "selectionIsRequired": {
"message": "Selection is required." "message": "Selection is required."
} }

View File

@ -1,6 +1,6 @@
<bit-simple-dialog> <bit-simple-dialog>
<span bitDialogTitle>{{ title | i18n }}</span> <span bitDialogTitle>{{ title | i18n }}</span>
<span bitDialogContent class="tw-text-sm"> <span bitDialogContent>
<div *ngIf="data.secretIds.length === 1"> <div *ngIf="data.secretIds.length === 1">
{{ "softDeleteSecretWarning" | i18n }} {{ "softDeleteSecretWarning" | i18n }}
</div> </div>
@ -8,7 +8,7 @@
</span> </span>
<div bitDialogFooter class="tw-flex tw-gap-2"> <div bitDialogFooter class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="primary" [bitAction]="delete"> <button type="button" bitButton buttonType="primary" [bitAction]="delete">
{{ title | i18n }} {{ submitButtonText | i18n }}
</button> </button>
<button type="button" bitButton buttonType="secondary" bitDialogClose> <button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "close" | i18n }} {{ "close" | i18n }}

View File

@ -27,11 +27,15 @@ export class SecretDeleteDialogComponent {
return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets"; return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets";
} }
get submitButtonText() {
return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets";
}
delete = async () => { delete = async () => {
await this.secretService.delete(this.data.secretIds); await this.secretService.delete(this.data.secretIds);
this.dialogRef.close(this.data.secretIds);
const message = const message =
this.data.secretIds.length === 1 ? "softDeleteSuccessToast" : "softDeletesSuccessToast"; this.data.secretIds.length === 1 ? "softDeleteSuccessToast" : "softDeletesSuccessToast";
this.dialogRef.close(this.data.secretIds);
this.platformUtilsService.showToast("success", null, this.i18nService.t(message)); this.platformUtilsService.showToast("success", null, this.i18nService.t(message));
}; };
} }

View File

@ -101,6 +101,42 @@ export class SecretService {
this._secret.next(null); this._secret.next(null);
} }
async getTrashedSecrets(organizationId: string): Promise<SecretListView[]> {
const r = await this.apiService.send(
"GET",
"/secrets/" + organizationId + "/trash",
null,
true,
true
);
return await this.createSecretsListView(organizationId, new SecretWithProjectsListResponse(r));
}
async deleteTrashed(organizationId: string, secretIds: string[]) {
await this.apiService.send(
"POST",
"/secrets/" + organizationId + "/trash/empty",
secretIds,
true,
true
);
this._secret.next(null);
}
async restoreTrashed(organizationId: string, secretIds: string[]) {
await this.apiService.send(
"POST",
"/secrets/" + organizationId + "/trash/restore",
secretIds,
true,
true
);
this._secret.next(null);
}
private async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> { private async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId); return await this.cryptoService.getOrgKey(organizationId);
} }

View File

@ -9,7 +9,7 @@ import { SecretsComponent } from "./secrets.component";
@NgModule({ @NgModule({
imports: [SecretsManagerSharedModule, SecretsRoutingModule], imports: [SecretsManagerSharedModule, SecretsRoutingModule],
declarations: [SecretsComponent, SecretDialogComponent, SecretDeleteDialogComponent], declarations: [SecretDeleteDialogComponent, SecretDialogComponent, SecretsComponent],
providers: [], providers: [],
}) })
export class SecretsModule {} export class SecretsModule {}

View File

@ -2,20 +2,26 @@
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i> <i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div> </div>
<sm-no-items *ngIf="secrets?.length == 0"> <ng-container *ngIf="secrets?.length == 0">
<ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container> <sm-no-items *ngIf="trash">
<ng-container slot="description">{{ "secretsNoItemsMessage" | i18n }}</ng-container> <ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container>
<button <ng-container slot="description">{{ "secretsTrashNoItemsMessage" | i18n }}</ng-container>
type="button" </sm-no-items>
slot="button" <sm-no-items *ngIf="!trash">
bitButton <ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container>
buttonType="secondary" <ng-container slot="description">{{ "secretsNoItemsMessage" | i18n }}</ng-container>
(click)="newSecretEvent.emit()" <button
> type="button"
<i class="bwi bwi-plus" aria-hidden="true"></i> slot="button"
{{ "newSecret" | i18n }} bitButton
</button> buttonType="secondary"
</sm-no-items> (click)="newSecretEvent.emit()"
>
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newSecret" | i18n }}
</button>
</sm-no-items>
</ng-container>
<bit-table *ngIf="secrets?.length >= 1" [dataSource]="dataSource"> <bit-table *ngIf="secrets?.length >= 1" [dataSource]="dataSource">
<ng-container header> <ng-container header>
@ -86,7 +92,7 @@
</td> </td>
<bit-menu #secretMenu> <bit-menu #secretMenu>
<button type="button" bitMenuItem (click)="editSecretEvent.emit(secret.id)"> <button type="button" bitMenuItem (click)="editSecretEvent.emit(secret.id)" *ngIf="!trash">
<i class="bwi bwi-fw bwi-pencil tw-text-xl" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-pencil tw-text-xl" aria-hidden="true"></i>
{{ "editSecret" | i18n }} {{ "editSecret" | i18n }}
</button> </button>
@ -98,9 +104,14 @@
<i class="bwi bwi-fw bwi-clone tw-text-xl" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone tw-text-xl" aria-hidden="true"></i>
{{ "copySecretValue" | i18n }} {{ "copySecretValue" | i18n }}
</button> </button>
<button type="button" bitMenuItem (click)="projectsEvent.emit(secret.id)"> <button
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i> type="button"
{{ "projects" | i18n }} bitMenuItem
(click)="restoreSecretsEvent.emit([secret.id])"
*ngIf="trash"
>
<i class="bwi bwi-fw bwi-refresh tw-text-xl" aria-hidden="true"></i>
{{ "restoreSecret" | i18n }}
</button> </button>
<button type="button" bitMenuItem (click)="deleteSecretsEvent.emit([secret.id])"> <button type="button" bitMenuItem (click)="deleteSecretsEvent.emit([secret.id])">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
@ -112,9 +123,9 @@
</bit-table> </bit-table>
<bit-menu #tableMenu> <bit-menu #tableMenu>
<button type="button" bitMenuItem> <button type="button" bitMenuItem (click)="bulkRestoreSecrets()" *ngIf="trash">
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-refresh tw-text-xl" aria-hidden="true"></i>
{{ "projects" | i18n }} <span>{{ "restoreSelected" | i18n }}</span>
</button> </button>
<button type="button" bitMenuItem (click)="bulkDeleteSecrets()"> <button type="button" bitMenuItem (click)="bulkDeleteSecrets()">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>

View File

@ -24,13 +24,15 @@ export class SecretsListComponent implements OnDestroy {
} }
private _secrets: SecretListView[]; private _secrets: SecretListView[];
@Input() trash: boolean;
@Output() editSecretEvent = new EventEmitter<string>(); @Output() editSecretEvent = new EventEmitter<string>();
@Output() copySecretNameEvent = new EventEmitter<string>(); @Output() copySecretNameEvent = new EventEmitter<string>();
@Output() copySecretValueEvent = new EventEmitter<string>(); @Output() copySecretValueEvent = new EventEmitter<string>();
@Output() projectsEvent = new EventEmitter<string>();
@Output() onSecretCheckedEvent = new EventEmitter<string[]>(); @Output() onSecretCheckedEvent = new EventEmitter<string[]>();
@Output() deleteSecretsEvent = new EventEmitter<string[]>(); @Output() deleteSecretsEvent = new EventEmitter<string[]>();
@Output() newSecretEvent = new EventEmitter(); @Output() newSecretEvent = new EventEmitter();
@Output() restoreSecretsEvent = new EventEmitter();
private destroy$: Subject<void> = new Subject<void>(); private destroy$: Subject<void> = new Subject<void>();
@ -64,4 +66,10 @@ export class SecretsListComponent implements OnDestroy {
this.deleteSecretsEvent.emit(this.selection.selected); this.deleteSecretsEvent.emit(this.selection.selected);
} }
} }
bulkRestoreSecrets() {
if (this.selection.selected.length >= 1) {
this.restoreSecretsEvent.emit(this.selection.selected);
}
}
} }

View File

@ -13,6 +13,7 @@ import { SecretsModule } from "./secrets/secrets.module";
import { ServiceAccountsModule } from "./service-accounts/service-accounts.module"; import { ServiceAccountsModule } from "./service-accounts/service-accounts.module";
import { SettingsModule } from "./settings/settings.module"; import { SettingsModule } from "./settings/settings.module";
import { SMGuard } from "./sm.guard"; import { SMGuard } from "./sm.guard";
import { TrashModule } from "./trash/trash.module";
const routes: Routes = [ const routes: Routes = [
buildFlaggedRoute("secretsManager", { buildFlaggedRoute("secretsManager", {
@ -49,6 +50,13 @@ const routes: Routes = [
titleId: "serviceAccounts", titleId: "serviceAccounts",
}, },
}, },
{
path: "trash",
loadChildren: () => TrashModule,
data: {
titleId: "trash",
},
},
{ {
path: "settings", path: "settings",
loadChildren: () => SettingsModule, loadChildren: () => SettingsModule,

View File

@ -0,0 +1,18 @@
<bit-simple-dialog>
<span bitDialogTitle>{{ title | i18n }}</span>
<span bitDialogContent>
{{
data.secretIds.length === 1
? ("hardDeleteSecretConfirmation" | i18n)
: ("hardDeleteSecretsConfirmation" | i18n)
}}
</span>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="primary" [bitAction]="delete">
{{ submitButtonText | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</bit-simple-dialog>

View File

@ -0,0 +1,42 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SecretService } from "../../secrets/secret.service";
export interface SecretHardDeleteOperation {
secretIds: string[];
organizationId: string;
}
@Component({
selector: "sm-secret-hard-delete-dialog",
templateUrl: "./secret-hard-delete.component.html",
})
export class SecretHardDeleteDialogComponent {
constructor(
public dialogRef: DialogRef,
private secretService: SecretService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
@Inject(DIALOG_DATA) public data: SecretHardDeleteOperation
) {}
get title() {
return this.data.secretIds.length === 1 ? "hardDeleteSecret" : "hardDeleteSecrets";
}
get submitButtonText() {
return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets";
}
delete = async () => {
await this.secretService.deleteTrashed(this.data.organizationId, this.data.secretIds);
const message =
this.data.secretIds.length === 1 ? "hardDeleteSuccessToast" : "hardDeletesSuccessToast";
this.dialogRef.close(this.data.secretIds);
this.platformUtilsService.showToast("success", null, this.i18nService.t(message));
};
}

View File

@ -0,0 +1,16 @@
<bit-simple-dialog>
<span bitDialogTitle>{{ title | i18n }}</span>
<span bitDialogContent>
{{
data.secretIds.length === 1 ? ("restoreSecretPrompt" | i18n) : ("restoreSecretsPrompt" | i18n)
}}
</span>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="primary" [bitAction]="restore">
{{ title | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</bit-simple-dialog>

View File

@ -0,0 +1,41 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SecretService } from "../../secrets/secret.service";
export interface SecretRestoreOperation {
secretIds: string[];
organizationId: string;
}
@Component({
selector: "sm-secret-restore-dialog",
templateUrl: "./secret-restore.component.html",
})
export class SecretRestoreDialogComponent {
constructor(
public dialogRef: DialogRef,
private secretService: SecretService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
@Inject(DIALOG_DATA) public data: SecretRestoreOperation
) {}
get title() {
return this.data.secretIds.length === 1 ? "restoreSecret" : "restoreSecrets";
}
restore = async () => {
let message = "";
await this.secretService.restoreTrashed(this.data.organizationId, this.data.secretIds);
message =
this.data.secretIds.length === 1
? "secretRestoredSuccessToast"
: "secretsRestoredSuccessToast";
this.dialogRef.close(this.data.secretIds);
this.platformUtilsService.showToast("success", null, this.i18nService.t(message));
};
}

View File

@ -0,0 +1,17 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { TrashComponent } from "./trash.component";
const routes: Routes = [
{
path: "",
component: TrashComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class TrashRoutingModule {}

View File

@ -0,0 +1,9 @@
<sm-header>
<sm-new-menu></sm-new-menu>
</sm-header>
<sm-secrets-list
(deleteSecretsEvent)="openDeleteSecret($event)"
(restoreSecretsEvent)="openRestoreSecret($event)"
[secrets]="secrets$ | async"
[trash]="true"
></sm-secrets-list>

View File

@ -0,0 +1,66 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatestWith, Observable, startWith, switchMap } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { SecretListView } from "../models/view/secret-list.view";
import { SecretService } from "../secrets/secret.service";
import {
SecretHardDeleteDialogComponent,
SecretHardDeleteOperation,
} from "./dialog/secret-hard-delete.component";
import {
SecretRestoreDialogComponent,
SecretRestoreOperation,
} from "./dialog/secret-restore.component";
@Component({
selector: "sm-trash",
templateUrl: "./trash.component.html",
})
export class TrashComponent implements OnInit {
secrets$: Observable<SecretListView[]>;
private organizationId: string;
constructor(
private route: ActivatedRoute,
private secretService: SecretService,
private dialogService: DialogService
) {}
ngOnInit() {
this.secrets$ = this.secretService.secret$.pipe(
startWith(null),
combineLatestWith(this.route.params),
switchMap(async ([_, params]) => {
this.organizationId = params.organizationId;
return await this.getSecrets();
})
);
}
private async getSecrets(): Promise<SecretListView[]> {
return await this.secretService.getTrashedSecrets(this.organizationId);
}
openDeleteSecret(secretIds: string[]) {
this.dialogService.open<unknown, SecretHardDeleteOperation>(SecretHardDeleteDialogComponent, {
data: {
secretIds: secretIds,
organizationId: this.organizationId,
},
});
}
openRestoreSecret(secretIds: string[]) {
this.dialogService.open<unknown, SecretRestoreOperation>(SecretRestoreDialogComponent, {
data: {
secretIds: secretIds,
organizationId: this.organizationId,
},
});
}
}

View File

@ -0,0 +1,15 @@
import { NgModule } from "@angular/core";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { SecretHardDeleteDialogComponent } from "./dialog/secret-hard-delete.component";
import { SecretRestoreDialogComponent } from "./dialog/secret-restore.component";
import { TrashRoutingModule } from "./trash-routing.module";
import { TrashComponent } from "./trash.component";
@NgModule({
imports: [SecretsManagerSharedModule, TrashRoutingModule],
declarations: [SecretHardDeleteDialogComponent, SecretRestoreDialogComponent, TrashComponent],
providers: [],
})
export class TrashModule {}