[SM-670] Restrict UI actions based on user permission (#5090)

* Restrict UI actions based on user permission

* Swap to hiding bulk option without permission

* Fix read/write assignment in project service

* Filter projects based on permission in dialog

* Fix encryption error for updating secret result

* Fix spinner (#5182)

* Swap to bit-no-items

* [SM-699] Projects bulk delete - add bulk confirmation dialog (#5200)

* Add bulk confirmation dialog

* Code review updates

* Code review - load projects

* code review - swap to observable

* Code review - remove oninit
This commit is contained in:
Thomas Avery 2023-04-26 13:09:30 -05:00 committed by GitHub
parent 95b1ea318c
commit 208e3f30b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 259 additions and 88 deletions

View File

@ -1264,7 +1264,7 @@
"example": "2"
}
}
},
},
"dataExportSuccess": {
"message": "Data successfully exported"
},
@ -6736,7 +6736,19 @@
"notAvailableForFreeOrganization": {
"message": "This feature is not available for free organizations. Contact your organization owner to upgrade."
},
"smProjectSecretsNoItemsNoAccess": {
"message": "Contact your organization's admin to manage secrets for this project.",
"description": "The message shown to the user under a project's secrets tab when the user only has read access to the project."
},
"enforceOnLoginDesc": {
"message": "Require existing members to change their passwords"
},
"smProjectDeleteAccessRestricted": {
"message": "You don't have permissions to delete this project",
"description": "The individual description shown to the user when the user doesn't have access to delete a project."
},
"smProjectsDeleteBulkConfirmation": {
"message": "The following projects can not be deleted. Would you like to continue?",
"description": "The message shown to the user when bulk deleting projects and the user doesn't have access to some projects."
}
}

View File

@ -4,4 +4,6 @@ export class ProjectListView {
name: string;
creationDate: string;
revisionDate: string;
read: boolean;
write: boolean;
}

View File

@ -4,9 +4,6 @@ export class ProjectView {
name: string;
creationDate: string;
revisionDate: string;
}
export class ProjectPermissionDetailsView extends ProjectView {
read: boolean;
write: boolean;
}

View File

@ -6,6 +6,8 @@ export class ProjectListItemResponse extends BaseResponse {
name: string;
creationDate: string;
revisionDate: string;
read: boolean;
write: boolean;
constructor(response: any) {
super(response);
@ -14,5 +16,7 @@ export class ProjectListItemResponse extends BaseResponse {
this.name = this.getResponseProperty("Name");
this.creationDate = this.getResponseProperty("CreationDate");
this.revisionDate = this.getResponseProperty("RevisionDate");
this.read = this.getResponseProperty("Read");
this.write = this.getResponseProperty("Write");
}
}

View File

@ -6,6 +6,8 @@ export class ProjectResponse extends BaseResponse {
name: string;
creationDate: string;
revisionDate: string;
read: boolean;
write: boolean;
constructor(response: any) {
super(response);
@ -14,15 +16,6 @@ export class ProjectResponse extends BaseResponse {
this.name = this.getResponseProperty("Name");
this.creationDate = this.getResponseProperty("CreationDate");
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}
export class ProjectPermissionDetailsResponse extends ProjectResponse {
read: boolean;
write: boolean;
constructor(response: any) {
super(response);
this.read = this.getResponseProperty("Read");
this.write = this.getResponseProperty("Write");
}

View File

@ -9,15 +9,12 @@ import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-cr
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ProjectListView } from "../models/view/project-list.view";
import { ProjectPermissionDetailsView, ProjectView } from "../models/view/project.view";
import { ProjectView } from "../models/view/project.view";
import { BulkOperationStatus } from "../shared/dialogs/bulk-status-dialog.component";
import { ProjectRequest } from "./models/requests/project.request";
import { ProjectListItemResponse } from "./models/responses/project-list-item.response";
import {
ProjectPermissionDetailsResponse,
ProjectResponse,
} from "./models/responses/project.response";
import { ProjectResponse } from "./models/responses/project.response";
@Injectable({
providedIn: "root",
@ -32,10 +29,10 @@ export class ProjectService {
private encryptService: EncryptService
) {}
async getByProjectId(projectId: string): Promise<ProjectPermissionDetailsView> {
async getByProjectId(projectId: string): Promise<ProjectView> {
const r = await this.apiService.send("GET", "/projects/" + projectId, null, true, true);
const projectResponse = new ProjectPermissionDetailsResponse(r);
return await this.createProjectPermissionDetailsView(projectResponse);
const projectResponse = new ProjectResponse(r);
return await this.createProjectView(projectResponse);
}
async getProjects(organizationId: string): Promise<ProjectListView[]> {
@ -99,9 +96,7 @@ export class ProjectService {
return request;
}
private async createProjectView(
projectResponse: ProjectResponse | ProjectPermissionDetailsResponse
) {
private async createProjectView(projectResponse: ProjectResponse) {
const orgKey = await this.getOrganizationKey(projectResponse.organizationId);
const projectView = new ProjectView();
@ -109,6 +104,8 @@ export class ProjectService {
projectView.organizationId = projectResponse.organizationId;
projectView.creationDate = projectResponse.creationDate;
projectView.revisionDate = projectResponse.revisionDate;
projectView.read = projectResponse.read;
projectView.write = projectResponse.write;
projectView.name = await this.encryptService.decryptToUtf8(
new EncString(projectResponse.name),
orgKey
@ -116,16 +113,6 @@ export class ProjectService {
return projectView;
}
private async createProjectPermissionDetailsView(
projectResponse: ProjectPermissionDetailsResponse
): Promise<ProjectPermissionDetailsView> {
return {
...(await this.createProjectView(projectResponse)),
read: projectResponse.read,
write: projectResponse.write,
};
}
private async createProjectsListView(
organizationId: string,
projects: ProjectListItemResponse[]
@ -136,6 +123,8 @@ export class ProjectService {
const projectListView = new ProjectListView();
projectListView.id = s.id;
projectListView.organizationId = s.organizationId;
projectListView.read = s.read;
projectListView.write = s.write;
projectListView.name = await this.encryptService.decryptToUtf8(
new EncString(s.name),
orgKey

View File

@ -1,18 +1,24 @@
<ng-container *ngIf="secrets$ | async as secrets; else spinner">
<div *ngIf="secrets.length > 0" class="float-right tw-mt-3 tw-items-center">
<button type="button" bitButton buttonType="secondary" (click)="openNewSecretDialog()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newSecret" | i18n }}
</button>
</div>
<sm-secrets-list
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"
(copySecretNameEvent)="copySecretName($event)"
(copySecretValueEvent)="copySecretValue($event)"
[secrets]="secrets"
></sm-secrets-list>
<ng-container *ngIf="{ project: project$ | async, secrets: secrets$ | async } as projectSecrets">
<ng-container *ngIf="projectSecrets?.secrets && projectSecrets?.project; else spinner">
<div
*ngIf="projectSecrets.secrets?.length > 0 && projectSecrets.project?.write"
class="float-right tw-mt-3 tw-items-center"
>
<button type="button" bitButton buttonType="secondary" (click)="openNewSecretDialog()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newSecret" | i18n }}
</button>
</div>
<sm-secrets-list
*ngIf="projectSecrets.secrets?.length > 0 || projectSecrets.project?.write; else contactAdmin"
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"
(copySecretNameEvent)="copySecretName($event)"
(copySecretValueEvent)="copySecretValue($event)"
[secrets]="projectSecrets.secrets"
></sm-secrets-list>
</ng-container>
</ng-container>
<ng-template #spinner>
@ -20,3 +26,10 @@
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>
<ng-template #contactAdmin>
<bit-no-items>
<ng-container slot="title">{{ "secretsNoItemsTitle" | i18n }}</ng-container>
<ng-container slot="description">{{ "smProjectSecretsNoItemsNoAccess" | i18n }}</ng-container>
</bit-no-items>
</ng-template>

View File

@ -1,11 +1,12 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatestWith, filter, Observable, startWith, switchMap } from "rxjs";
import { combineLatest, combineLatestWith, filter, Observable, startWith, switchMap } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { DialogService } from "@bitwarden/components";
import { ProjectView } from "../../models/view/project.view";
import { SecretListView } from "../../models/view/secret-list.view";
import {
SecretDeleteDialogComponent,
@ -29,6 +30,7 @@ export class ProjectSecretsComponent {
private organizationId: string;
private projectId: string;
protected project$: Observable<ProjectView>;
constructor(
private route: ActivatedRoute,
@ -46,6 +48,12 @@ export class ProjectSecretsComponent {
startWith(null)
);
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
switchMap(([params, _]) => {
return this.projectService.getByProjectId(params.projectId);
})
);
this.secrets$ = this.secretService.secret$.pipe(
startWith(null),
combineLatestWith(this.route.params, currentProjectEdited),

View File

@ -4,7 +4,7 @@ import { combineLatest, filter, Observable, startWith, Subject, switchMap, takeU
import { DialogService } from "@bitwarden/components";
import { ProjectPermissionDetailsView } from "../../models/view/project.view";
import { ProjectView } from "../../models/view/project.view";
import {
OperationType,
ProjectDialogComponent,
@ -17,7 +17,7 @@ import { ProjectService } from "../project.service";
templateUrl: "./project.component.html",
})
export class ProjectComponent implements OnInit, OnDestroy {
protected project$: Observable<ProjectPermissionDetailsView>;
protected project$: Observable<ProjectView>;
private organizationId: string;
private projectId: string;

View File

@ -1,11 +1,17 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, Observable, startWith, switchMap } from "rxjs";
import { combineLatest, lastValueFrom, Observable, startWith, switchMap } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { ProjectListView } from "../../models/view/project-list.view";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import {
BulkConfirmationDetails,
BulkConfirmationDialogComponent,
BulkConfirmationResult,
BulkConfirmationStatus,
} from "../../shared/dialogs/bulk-confirmation-dialog.component";
import {
ProjectDeleteDialogComponent,
ProjectDeleteOperation,
@ -70,11 +76,48 @@ export class ProjectsComponent implements OnInit {
});
}
openDeleteProjectDialog(event: ProjectListView[]) {
this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: {
projects: event,
},
async openDeleteProjectDialog(projects: ProjectListView[]) {
if (projects.some((project) => project.write == false)) {
const readOnlyProjects = projects.filter((project) => project.write == false);
const writeProjects = projects.filter((project) => project.write);
const dialogRef = this.dialogService.open<unknown, BulkConfirmationDetails>(
BulkConfirmationDialogComponent,
{
data: {
title: "deleteProjects",
columnTitle: "projectName",
message: "smProjectsDeleteBulkConfirmation",
details: this.getBulkConfirmationDetails(readOnlyProjects),
},
}
);
const result = await lastValueFrom(dialogRef.closed);
if (result == BulkConfirmationResult.Continue) {
this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: {
projects: writeProjects,
},
});
}
} else {
this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: {
projects,
},
});
}
}
private getBulkConfirmationDetails(projects: ProjectListView[]): BulkConfirmationStatus[] {
return projects.map((project) => {
return {
id: project.id,
name: project.name,
description: "smProjectDeleteAccessRestricted",
};
});
}
}

View File

@ -62,6 +62,8 @@ export class SecretDialogComponent implements OnInit {
} else if (this.data.operation !== OperationType.Add) {
this.dialogRef.close();
throw new Error(`The secret dialog was not called with the appropriate operation values.`);
} else if (this.data.operation == OperationType.Add) {
await this.loadProjects(true);
}
if (this.data.projectId) {
@ -72,15 +74,14 @@ export class SecretDialogComponent implements OnInit {
this.formGroup.get("project").removeValidators(Validators.required);
this.formGroup.get("project").updateValueAndValidity();
}
this.projects = await this.projectService
.getProjects(this.data.organizationId)
.then((projects) => projects.sort((a, b) => a.name.localeCompare(b.name)));
}
async loadData() {
this.formGroup.disable();
const secret: SecretView = await this.secretService.getBySecretId(this.data.secretId);
await this.loadProjects(secret.write);
this.formGroup.setValue({
name: secret.name,
value: secret.value,
@ -95,6 +96,16 @@ export class SecretDialogComponent implements OnInit {
}
}
async loadProjects(filterByPermission: boolean) {
this.projects = await this.projectService
.getProjects(this.data.organizationId)
.then((projects) => projects.sort((a, b) => a.name.localeCompare(b.name)));
if (filterByPermission) {
this.projects = this.projects.filter((p) => p.write);
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();

View File

@ -233,10 +233,9 @@ export class SecretService {
projects.map(async (s: SecretProjectResponse) => {
const projectsMappedToSecretView = new SecretProjectView();
projectsMappedToSecretView.id = s.id;
projectsMappedToSecretView.name = await this.encryptService.decryptToUtf8(
new EncString(s.name),
orgKey
);
projectsMappedToSecretView.name = s.name
? await this.encryptService.decryptToUtf8(new EncString(s.name), orgKey)
: null;
return projectsMappedToSecretView;
})
);

View File

@ -50,16 +50,21 @@ export class AccessRemovalDialogComponent implements OnInit {
await this.accessPolicyService.updateAccessPolicy(
AccessSelectorComponent.getBaseAccessPolicyView(this.data.policy)
);
this.refreshPolicyChanges();
}
this.dialogRef.close();
};
cancel = () => {
this.refreshPolicyChanges();
this.dialogRef.close();
};
private refreshPolicyChanges() {
if (this.data.type == "project") {
this.accessPolicyService.refreshProjectAccessPolicyChanges();
} else if (this.data.type == "service-account") {
this.accessPolicyService.refreshServiceAccountAccessPolicyChanges();
}
this.dialogRef.close();
};
}
}

View File

@ -0,0 +1,38 @@
<bit-dialog>
<ng-container bitDialogTitle>
{{ data.title | i18n }}
</ng-container>
<div bitDialogContent>
{{ data.message | i18n }}
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ data.columnTitle | i18n }}</th>
<th bitCell>{{ "description" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let detail of data.details">
<td bitCell>{{ detail.name }}</td>
<td bitCell>{{ detail.description | i18n }}</td>
</tr>
</ng-template>
</bit-table>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button
type="button"
bitButton
buttonType="primary"
bitFormButton
(click)="dialogRef.close(bulkConfirmationResult.Continue)"
>
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>

View File

@ -0,0 +1,48 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
export interface BulkConfirmationDetails {
title: string;
columnTitle: string;
message: string;
details: BulkConfirmationStatus[];
}
export interface BulkConfirmationStatus {
id: string;
name: string;
description: string;
}
export enum BulkConfirmationResult {
Continue,
Cancel,
}
@Component({
selector: "sm-bulk-confirmation-dialog",
templateUrl: "./bulk-confirmation-dialog.component.html",
})
export class BulkConfirmationDialogComponent implements OnInit {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) public data: BulkConfirmationDetails
) {}
protected bulkConfirmationResult = BulkConfirmationResult;
ngOnInit(): void {
// TODO remove null checks once strictNullChecks in TypeScript is turned on.
if (
!this.data.title ||
!this.data.columnTitle ||
!this.data.message ||
!(this.data.details?.length >= 1)
) {
this.dialogRef.close();
throw new Error(
"The bulk confirmation dialog was not called with the appropriate operation values."
);
}
}
}

View File

@ -35,6 +35,7 @@
<th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0">
<button
*ngIf="hasWriteAccessOnSelected$ | async"
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
@ -74,7 +75,12 @@
></button>
</td>
<bit-menu #projectMenu>
<button type="button" bitMenuItem (click)="editProjectEvent.emit(project.id)">
<button
*ngIf="project.write"
type="button"
bitMenuItem
(click)="editProjectEvent.emit(project.id)"
>
<i class="bwi bwi-fw bwi-pencil" aria-hidden="true"></i>
{{ "editProject" | i18n }}
</button>
@ -82,7 +88,7 @@
<i class="bwi bwi-fw bwi-eye" aria-hidden="true"></i>
{{ "viewProject" | i18n }}
</a>
<button type="button" bitMenuItem (click)="deleteProject(project.id)">
<button *ngIf="project.write" type="button" bitMenuItem (click)="deleteProject(project.id)">
<i class="bwi bwi-fw bwi-trash tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteProject" | i18n }}</span>
</button>

View File

@ -1,6 +1,6 @@
import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { map } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
@ -12,9 +12,7 @@ import { ProjectListView } from "../models/view/project-list.view";
selector: "sm-projects-list",
templateUrl: "./projects-list.component.html",
})
export class ProjectsListComponent implements OnDestroy {
protected dataSource = new TableDataSource<ProjectListView>();
export class ProjectsListComponent {
@Input()
get projects(): ProjectListView[] {
return this._projects;
@ -33,26 +31,18 @@ export class ProjectsListComponent implements OnDestroy {
@Output() editProjectEvent = new EventEmitter<string>();
@Output() deleteProjectEvent = new EventEmitter<ProjectListView[]>();
@Output() onProjectCheckedEvent = new EventEmitter<string[]>();
@Output() newProjectEvent = new EventEmitter();
private destroy$: Subject<void> = new Subject<void>();
selection = new SelectionModel<string>(true, []);
protected dataSource = new TableDataSource<ProjectListView>();
protected hasWriteAccessOnSelected$ = this.selection.changed.pipe(
map((_) => this.selectedHasWriteAccess())
);
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService
) {
this.selection.changed
.pipe(takeUntil(this.destroy$))
.subscribe((_) => this.onProjectCheckedEvent.emit(this.selection.selected));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
) {}
isAllSelected() {
const numSelected = this.selection.selected.length;
@ -83,4 +73,14 @@ export class ProjectsListComponent implements OnDestroy {
);
}
}
private selectedHasWriteAccess() {
const selectedProjects = this.projects.filter((project) =>
this.selection.isSelected(project.id)
);
if (selectedProjects.some((project) => project.write)) {
return true;
}
return false;
}
}

View File

@ -8,6 +8,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { AccessSelectorComponent } from "./access-policies/access-selector.component";
import { AccessRemovalDialogComponent } from "./access-policies/dialogs/access-removal-dialog.component";
import { BulkConfirmationDialogComponent } from "./dialogs/bulk-confirmation-dialog.component";
import { BulkStatusDialogComponent } from "./dialogs/bulk-status-dialog.component";
import { HeaderComponent } from "./header.component";
import { NewMenuComponent } from "./new-menu.component";
@ -30,6 +31,7 @@ import { SecretsListComponent } from "./secrets-list.component";
AccessRemovalDialogComponent,
AccessSelectorComponent,
BulkStatusDialogComponent,
BulkConfirmationDialogComponent,
HeaderComponent,
NewMenuComponent,
ProjectsListComponent,
@ -40,6 +42,7 @@ import { SecretsListComponent } from "./secrets-list.component";
declarations: [
AccessRemovalDialogComponent,
BulkStatusDialogComponent,
BulkConfirmationDialogComponent,
HeaderComponent,
NewMenuComponent,
ProjectsListComponent,