Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/web/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -7432,6 +7432,29 @@
"skipToContent": {
"message": "Skip to content"
},
"moveToProject": {
"message": "Move to project"
},
"bulkMoveToProjectCallout": {
"message": "Moving secrets will give access to any users assigned to a project."
},
"smSecretsMoveBulkConfirmation": {
"message": "The following secrets can not be moved. Would you like to continue?",
"description": "The message shown to users when bulk moving secrets into a project and the user doesn't have access to edit some secrets."
},
"smSecretMoveAccessRestricted": {
"message": "You don't have permissions to move this secret",
"description": "The individual description shown to the user when the user doesn't have access to move a secret."
},
"moveSecrets": {
"message": "Move secrets"
},
"moveTo": {
"message": "Move to"
},
"currentProject": {
"message": "Current project"
},
"managePermissionRequired": {
"message": "At least one member or group must have can manage permission."
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<form [formGroup]="formGroup" [bitSubmit]="move">
<bit-dialog dialogSize="large">
<ng-container bitDialogTitle>
{{ "moveToProject" | i18n }}
<span class="tw-text-sm tw-text-muted"
>{{ secretsCount }}
{{ (secretsCount === 1 ? "secret" : "secrets") | i18n | lowercase }}</span
>
</ng-container>
<div bitDialogContent class="tw-relative">
<bit-callout type="warning">
{{ "bulkMoveToProjectCallout" | i18n }}
</bit-callout>
<bit-form-field class="tw-mb-0 tw-mt-3">
<bit-label>{{ "project" | i18n }}</bit-label>
<bit-select bitInput name="project" formControlName="project">
<bit-option value="" [label]="'selectPlaceholder' | i18n"></bit-option>
<bit-option *ngFor="let p of projects" [value]="p.id" [label]="p.name"></bit-option>
</bit-select>
</bit-form-field>
<ng-container *ngIf="selectedProjectId$ | async as selectedProjectId">
<bit-table *ngIf="selectedProjectId != null" [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell>{{ "secret" | i18n }}</th>
<th bitCell>{{ "currentProject" | i18n }}</th>
<th></th>
<th bitCell>{{ "moveTo" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let secret of rows$ | async">
<td bitCell>
{{ secret.name }}
</td>
<td bitCell>
<span
*ngIf="secret.currentProject === undefined"
bitBadge
badgeType="warning"
class="tw-ml-1"
>
<i class="bwi bwi-fw bwi-exclamation-triangle tw-mr-1" aria-hidden="true"></i>
{{ "unassigned" | i18n }}
</span>
<span
*ngIf="secret.currentProject !== undefined"
bitBadge
badgeType="secondary"
class="tw-ml-1"
[title]="secret.currentProject"
>
{{ secret.currentProject | ellipsis: 32 }}
</span>
</td>
<td class="tw-items-start">
<span>
<i class="bwi bwi-fw bwi-lg bwi-long-arrow-right" aria-hidden="true"></i>
</span>
</td>
<td bitCell>
<span bitBadge badgeType="secondary" class="tw-ml-1" [title]="secret.moveTo">
{{ secret.moveTo | ellipsis: 32 }}
</span>
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
</div>
<ng-container bitDialogFooter>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[disabled]="formGroup.invalid"
>
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Observable, Subject, takeUntil } from "rxjs";

import { Utils } from "@bitwarden/common/platform/misc/utils";
import { TableDataSource } from "@bitwarden/components";

import { ProjectListView } from "../../models/view/project-list.view";
import { SecretListView } from "../../models/view/secret-list.view";
import { ProjectService } from "../../projects/project.service";
import { SecretService } from "../secret.service";

export interface SecretMoveProjectOperation {
secrets: SecretListView[];
organizationId: string;
}

type Secret = { name: string; currentProject?: string; moveTo: string };

@Component({
templateUrl: "./secret-move-project.component.html",
})
export class SecretMoveProjectComponent implements OnInit, OnDestroy {
protected formGroup = new FormGroup({
project: new FormControl("", [Validators.required]),
});

projects: ProjectListView[];
secretsCount: number;
selectedProjectId$: Observable<string | undefined>;
private destroy$ = new Subject<void>();
dataSource = new TableDataSource<Secret>();

constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: SecretMoveProjectOperation,
private projectService: ProjectService,
private secretService: SecretService,
) {
this.secretsCount = data.secrets.length;
this.selectedProjectId$ = this.formGroup.controls.project.valueChanges;

this.selectedProjectId$.pipe(takeUntil(this.destroy$)).subscribe((projectId) => {
if (Utils.isNullOrWhitespace(projectId)) {
this.dataSource.data = [];
return;
}

const chosenProjectName = this.projects.find((p) => p.id === projectId)?.name;

if (!chosenProjectName) {
return { showTable: false };
}

const data = this.data.secrets.map((s) => {
if (s.projects == null || s.projects.length === 0) {
return { name: s.name, moveTo: chosenProjectName };
}

// TODO: The logic will need updating if/when secrets can have more than one project
const currentProject = s.projects[0];
return { name: s.name, moveTo: chosenProjectName, currentProject: currentProject.name };
});

this.dataSource.data = data;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

async ngOnInit() {
this.projects = await this.projectService
.getProjects(this.data.organizationId)
.then((projects) => projects.filter((p) => p.write))
.then((projects) => projects.sort((a, b) => a.name.localeCompare(b.name)));
}

move = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
await this.secretService.bulkMoveToProject(
this.data.organizationId,
this.data.secrets.map((s) => s.id),
this.formGroup.controls.project.value,
);

this.dialogRef.close();
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ export class SecretService {
this._secret.next(null);
}

async bulkMoveToProject(organizationId: string, secretIds: string[], projectId: string) {
await this.apiService.send(
"POST",
"/organizations/" + organizationId + "/secrets/move",
{
secrets: secretIds,
project: projectId,
},
true,
true,
);

this._secret.next(null);
}

private async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
(copySecretNameEvent)="copySecretName($event)"
(copySecretValueEvent)="copySecretValue($event)"
(copySecretUuidEvent)="copySecretUuid($event)"
(bulkMoveToProjectEvent)="openMoveToProjectDialog($event)"
[secrets]="secrets$ | async"
[search]="search"
></sm-secrets-list>
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatestWith, Observable, startWith, switchMap } from "rxjs";
import { combineLatestWith, lastValueFrom, Observable, startWith, switchMap } from "rxjs";

import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";

import { SecretListView } from "../models/view/secret-list.view";
import {
BulkConfirmationDetails,
BulkConfirmationDialogComponent,
BulkConfirmationResult,
} from "../shared/dialogs/bulk-confirmation-dialog.component";
import { SecretsListComponent } from "../shared/secrets-list.component";

import {
Expand All @@ -19,6 +24,10 @@ import {
SecretDialogComponent,
SecretOperation,
} from "./dialog/secret-dialog.component";
import {
SecretMoveProjectComponent,
SecretMoveProjectOperation,
} from "./dialog/secret-move-project.component";
import { SecretService } from "./secret.service";

@Component({
Expand Down Expand Up @@ -107,4 +116,41 @@ export class SecretsComponent implements OnInit {
copySecretUuid(id: string) {
SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService);
}

async openMoveToProjectDialog(secrets: SecretListView[]) {
let secretsToMove = secrets;
const readonlySecrets = secrets.filter((secret) => secret.write == false);
if (readonlySecrets.length > 0) {
const dialogRef = this.dialogService.open<BulkConfirmationResult, BulkConfirmationDetails>(
BulkConfirmationDialogComponent,
{
data: {
title: "moveSecrets",
columnTitle: "secret",
message: "smSecretsMoveBulkConfirmation",
details: readonlySecrets.map((secret) => ({
id: secret.id,
name: secret.name,
description: "smSecretMoveAccessRestricted",
})),
},
},
);

const result = await lastValueFrom(dialogRef.closed);

if (result !== BulkConfirmationResult.Continue) {
return;
}

secretsToMove = secrets.filter((secret) => secret.write);
}

this.dialogService.open<unknown, SecretMoveProjectOperation>(SecretMoveProjectComponent, {
data: {
organizationId: this.organizationId,
secrets: secretsToMove,
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { SecretsManagerSharedModule } from "../shared/sm-shared.module";

import { SecretDeleteDialogComponent } from "./dialog/secret-delete.component";
import { SecretDialogComponent } from "./dialog/secret-dialog.component";
import { SecretMoveProjectComponent } from "./dialog/secret-move-project.component";
import { SecretsRoutingModule } from "./secrets-routing.module";
import { SecretsComponent } from "./secrets.component";

@NgModule({
imports: [SecretsManagerSharedModule, SecretsRoutingModule],
declarations: [SecretDeleteDialogComponent, SecretDialogComponent, SecretsComponent],
declarations: [
SecretDeleteDialogComponent,
SecretDialogComponent,
SecretsComponent,
SecretMoveProjectComponent,
],
providers: [],
})
export class SecretsModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@
<i class="bwi bwi-fw bwi-refresh" aria-hidden="true"></i>
<span>{{ "restoreSelected" | i18n }}</span>
</button>
<button
type="button"
bitMenuItem
*ngIf="!trash && hasWriteAccessOnSelected$ | async"
(click)="bulkMoveToProject()"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
<span>{{ "moveToProject" | i18n }}</span>
</button>
<button type="button" bitMenuItem (click)="bulkDeleteSecrets()">
<i class="bwi bwi-fw bwi-trash tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteSecrets" | i18n }}</span>
Expand Down
Loading