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:
parent
208be8dfbf
commit
d11f03cb78
|
@ -5734,6 +5734,12 @@
|
|||
"deleteSecrets":{
|
||||
"message": "Delete secrets"
|
||||
},
|
||||
"hardDeleteSecret":{
|
||||
"message": "Permanently delete secret"
|
||||
},
|
||||
"hardDeleteSecrets":{
|
||||
"message": "Permanently delete secrets"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
|
@ -5788,6 +5794,9 @@
|
|||
"secretsNoItemsMessage":{
|
||||
"message": "To get started, add a new secret or import secrets."
|
||||
},
|
||||
"secretsTrashNoItemsMessage":{
|
||||
"message": "There are no secrets in the trash."
|
||||
},
|
||||
"serviceAccountsNoItemsTitle":{
|
||||
"message":"Nothing to show yet"
|
||||
},
|
||||
|
@ -5833,6 +5842,15 @@
|
|||
"softDeletesSuccessToast":{
|
||||
"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":{
|
||||
"message":"Service account created"
|
||||
},
|
||||
|
@ -5911,6 +5929,9 @@
|
|||
"softDeleteSuccessToast":{
|
||||
"message":"Secret sent to trash"
|
||||
},
|
||||
"hardDeleteSuccessToast":{
|
||||
"message":"Secret permanently deleted"
|
||||
},
|
||||
"searchProjects":{
|
||||
"message":"Search projects"
|
||||
},
|
||||
|
@ -6399,6 +6420,24 @@
|
|||
"errorReadingImportFile": {
|
||||
"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": {
|
||||
"message": "Selection is required."
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>{{ title | i18n }}</span>
|
||||
<span bitDialogContent class="tw-text-sm">
|
||||
<span bitDialogContent>
|
||||
<div *ngIf="data.secretIds.length === 1">
|
||||
{{ "softDeleteSecretWarning" | i18n }}
|
||||
</div>
|
||||
|
@ -8,7 +8,7 @@
|
|||
</span>
|
||||
<div bitDialogFooter class="tw-flex tw-gap-2">
|
||||
<button type="button" bitButton buttonType="primary" [bitAction]="delete">
|
||||
{{ title | i18n }}
|
||||
{{ submitButtonText | i18n }}
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>
|
||||
{{ "close" | i18n }}
|
||||
|
|
|
@ -27,11 +27,15 @@ export class SecretDeleteDialogComponent {
|
|||
return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets";
|
||||
}
|
||||
|
||||
get submitButtonText() {
|
||||
return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets";
|
||||
}
|
||||
|
||||
delete = async () => {
|
||||
await this.secretService.delete(this.data.secretIds);
|
||||
this.dialogRef.close(this.data.secretIds);
|
||||
const message =
|
||||
this.data.secretIds.length === 1 ? "softDeleteSuccessToast" : "softDeletesSuccessToast";
|
||||
this.dialogRef.close(this.data.secretIds);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t(message));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -101,6 +101,42 @@ export class SecretService {
|
|||
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> {
|
||||
return await this.cryptoService.getOrgKey(organizationId);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { SecretsComponent } from "./secrets.component";
|
|||
|
||||
@NgModule({
|
||||
imports: [SecretsManagerSharedModule, SecretsRoutingModule],
|
||||
declarations: [SecretsComponent, SecretDialogComponent, SecretDeleteDialogComponent],
|
||||
declarations: [SecretDeleteDialogComponent, SecretDialogComponent, SecretsComponent],
|
||||
providers: [],
|
||||
})
|
||||
export class SecretsModule {}
|
||||
|
|
|
@ -2,20 +2,26 @@
|
|||
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
|
||||
</div>
|
||||
|
||||
<sm-no-items *ngIf="secrets?.length == 0">
|
||||
<ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "secretsNoItemsMessage" | i18n }}</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
(click)="newSecretEvent.emit()"
|
||||
>
|
||||
<i class="bwi bwi-plus" aria-hidden="true"></i>
|
||||
{{ "newSecret" | i18n }}
|
||||
</button>
|
||||
</sm-no-items>
|
||||
<ng-container *ngIf="secrets?.length == 0">
|
||||
<sm-no-items *ngIf="trash">
|
||||
<ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "secretsTrashNoItemsMessage" | i18n }}</ng-container>
|
||||
</sm-no-items>
|
||||
<sm-no-items *ngIf="!trash">
|
||||
<ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "secretsNoItemsMessage" | i18n }}</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
(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">
|
||||
<ng-container header>
|
||||
|
@ -86,7 +92,7 @@
|
|||
</td>
|
||||
|
||||
<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>
|
||||
{{ "editSecret" | i18n }}
|
||||
</button>
|
||||
|
@ -98,9 +104,14 @@
|
|||
<i class="bwi bwi-fw bwi-clone tw-text-xl" aria-hidden="true"></i>
|
||||
{{ "copySecretValue" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="projectsEvent.emit(secret.id)">
|
||||
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i>
|
||||
{{ "projects" | i18n }}
|
||||
<button
|
||||
type="button"
|
||||
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 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>
|
||||
|
@ -112,9 +123,9 @@
|
|||
</bit-table>
|
||||
|
||||
<bit-menu #tableMenu>
|
||||
<button type="button" bitMenuItem>
|
||||
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i>
|
||||
{{ "projects" | i18n }}
|
||||
<button type="button" bitMenuItem (click)="bulkRestoreSecrets()" *ngIf="trash">
|
||||
<i class="bwi bwi-fw bwi-refresh tw-text-xl" aria-hidden="true"></i>
|
||||
<span>{{ "restoreSelected" | i18n }}</span>
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="bulkDeleteSecrets()">
|
||||
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
|
||||
|
|
|
@ -24,13 +24,15 @@ export class SecretsListComponent implements OnDestroy {
|
|||
}
|
||||
private _secrets: SecretListView[];
|
||||
|
||||
@Input() trash: boolean;
|
||||
|
||||
@Output() editSecretEvent = new EventEmitter<string>();
|
||||
@Output() copySecretNameEvent = new EventEmitter<string>();
|
||||
@Output() copySecretValueEvent = new EventEmitter<string>();
|
||||
@Output() projectsEvent = new EventEmitter<string>();
|
||||
@Output() onSecretCheckedEvent = new EventEmitter<string[]>();
|
||||
@Output() deleteSecretsEvent = new EventEmitter<string[]>();
|
||||
@Output() newSecretEvent = new EventEmitter();
|
||||
@Output() restoreSecretsEvent = new EventEmitter();
|
||||
|
||||
private destroy$: Subject<void> = new Subject<void>();
|
||||
|
||||
|
@ -64,4 +66,10 @@ export class SecretsListComponent implements OnDestroy {
|
|||
this.deleteSecretsEvent.emit(this.selection.selected);
|
||||
}
|
||||
}
|
||||
|
||||
bulkRestoreSecrets() {
|
||||
if (this.selection.selected.length >= 1) {
|
||||
this.restoreSecretsEvent.emit(this.selection.selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { SecretsModule } from "./secrets/secrets.module";
|
|||
import { ServiceAccountsModule } from "./service-accounts/service-accounts.module";
|
||||
import { SettingsModule } from "./settings/settings.module";
|
||||
import { SMGuard } from "./sm.guard";
|
||||
import { TrashModule } from "./trash/trash.module";
|
||||
|
||||
const routes: Routes = [
|
||||
buildFlaggedRoute("secretsManager", {
|
||||
|
@ -49,6 +50,13 @@ const routes: Routes = [
|
|||
titleId: "serviceAccounts",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "trash",
|
||||
loadChildren: () => TrashModule,
|
||||
data: {
|
||||
titleId: "trash",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
loadChildren: () => SettingsModule,
|
||||
|
|
|
@ -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>
|
|
@ -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));
|
||||
};
|
||||
}
|
|
@ -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>
|
|
@ -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));
|
||||
};
|
||||
}
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 {}
|
Loading…
Reference in New Issue