1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

[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

@ -6736,7 +6736,19 @@
"notAvailableForFreeOrganization": { "notAvailableForFreeOrganization": {
"message": "This feature is not available for free organizations. Contact your organization owner to upgrade." "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": { "enforceOnLoginDesc": {
"message": "Require existing members to change their passwords" "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; name: string;
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
read: boolean;
write: boolean;
} }

View File

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

View File

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

View File

@ -1,18 +1,24 @@
<ng-container *ngIf="secrets$ | async as secrets; else spinner"> <ng-container *ngIf="{ project: project$ | async, secrets: secrets$ | async } as projectSecrets">
<div *ngIf="secrets.length > 0" class="float-right tw-mt-3 tw-items-center"> <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()"> <button type="button" bitButton buttonType="secondary" (click)="openNewSecretDialog()">
<i class="bwi bwi-plus" aria-hidden="true"></i> <i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newSecret" | i18n }} {{ "newSecret" | i18n }}
</button> </button>
</div> </div>
<sm-secrets-list <sm-secrets-list
*ngIf="projectSecrets.secrets?.length > 0 || projectSecrets.project?.write; else contactAdmin"
(deleteSecretsEvent)="openDeleteSecret($event)" (deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()" (newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)" (editSecretEvent)="openEditSecret($event)"
(copySecretNameEvent)="copySecretName($event)" (copySecretNameEvent)="copySecretName($event)"
(copySecretValueEvent)="copySecretValue($event)" (copySecretValueEvent)="copySecretValue($event)"
[secrets]="secrets" [secrets]="projectSecrets.secrets"
></sm-secrets-list> ></sm-secrets-list>
</ng-container>
</ng-container> </ng-container>
<ng-template #spinner> <ng-template #spinner>
@ -20,3 +26,10 @@
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i> <i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div> </div>
</ng-template> </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 { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; 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 { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { ProjectView } from "../../models/view/project.view";
import { SecretListView } from "../../models/view/secret-list.view"; import { SecretListView } from "../../models/view/secret-list.view";
import { import {
SecretDeleteDialogComponent, SecretDeleteDialogComponent,
@ -29,6 +30,7 @@ export class ProjectSecretsComponent {
private organizationId: string; private organizationId: string;
private projectId: string; private projectId: string;
protected project$: Observable<ProjectView>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -46,6 +48,12 @@ export class ProjectSecretsComponent {
startWith(null) startWith(null)
); );
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
switchMap(([params, _]) => {
return this.projectService.getByProjectId(params.projectId);
})
);
this.secrets$ = this.secretService.secret$.pipe( this.secrets$ = this.secretService.secret$.pipe(
startWith(null), startWith(null),
combineLatestWith(this.route.params, currentProjectEdited), 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 { DialogService } from "@bitwarden/components";
import { ProjectPermissionDetailsView } from "../../models/view/project.view"; import { ProjectView } from "../../models/view/project.view";
import { import {
OperationType, OperationType,
ProjectDialogComponent, ProjectDialogComponent,
@ -17,7 +17,7 @@ import { ProjectService } from "../project.service";
templateUrl: "./project.component.html", templateUrl: "./project.component.html",
}) })
export class ProjectComponent implements OnInit, OnDestroy { export class ProjectComponent implements OnInit, OnDestroy {
protected project$: Observable<ProjectPermissionDetailsView>; protected project$: Observable<ProjectView>;
private organizationId: string; private organizationId: string;
private projectId: string; private projectId: string;

View File

@ -1,11 +1,17 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; 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 { DialogService } from "@bitwarden/components";
import { ProjectListView } from "../../models/view/project-list.view"; import { ProjectListView } from "../../models/view/project-list.view";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import {
BulkConfirmationDetails,
BulkConfirmationDialogComponent,
BulkConfirmationResult,
BulkConfirmationStatus,
} from "../../shared/dialogs/bulk-confirmation-dialog.component";
import { import {
ProjectDeleteDialogComponent, ProjectDeleteDialogComponent,
ProjectDeleteOperation, ProjectDeleteOperation,
@ -70,11 +76,48 @@ export class ProjectsComponent implements OnInit {
}); });
} }
openDeleteProjectDialog(event: ProjectListView[]) { 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, { this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: { data: {
projects: event, 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) { } else if (this.data.operation !== OperationType.Add) {
this.dialogRef.close(); this.dialogRef.close();
throw new Error(`The secret dialog was not called with the appropriate operation values.`); 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) { if (this.data.projectId) {
@ -72,15 +74,14 @@ export class SecretDialogComponent implements OnInit {
this.formGroup.get("project").removeValidators(Validators.required); this.formGroup.get("project").removeValidators(Validators.required);
this.formGroup.get("project").updateValueAndValidity(); 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() { async loadData() {
this.formGroup.disable(); this.formGroup.disable();
const secret: SecretView = await this.secretService.getBySecretId(this.data.secretId); const secret: SecretView = await this.secretService.getBySecretId(this.data.secretId);
await this.loadProjects(secret.write);
this.formGroup.setValue({ this.formGroup.setValue({
name: secret.name, name: secret.name,
value: secret.value, 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 { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();

View File

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

View File

@ -50,16 +50,21 @@ export class AccessRemovalDialogComponent implements OnInit {
await this.accessPolicyService.updateAccessPolicy( await this.accessPolicyService.updateAccessPolicy(
AccessSelectorComponent.getBaseAccessPolicyView(this.data.policy) AccessSelectorComponent.getBaseAccessPolicyView(this.data.policy)
); );
this.refreshPolicyChanges();
} }
this.dialogRef.close(); this.dialogRef.close();
}; };
cancel = () => { cancel = () => {
this.refreshPolicyChanges();
this.dialogRef.close();
};
private refreshPolicyChanges() {
if (this.data.type == "project") { if (this.data.type == "project") {
this.accessPolicyService.refreshProjectAccessPolicyChanges(); this.accessPolicyService.refreshProjectAccessPolicyChanges();
} else if (this.data.type == "service-account") { } else if (this.data.type == "service-account") {
this.accessPolicyService.refreshServiceAccountAccessPolicyChanges(); 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 bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0"> <th bitCell class="tw-w-0">
<button <button
*ngIf="hasWriteAccessOnSelected$ | async"
type="button" type="button"
bitIconButton="bwi-ellipsis-v" bitIconButton="bwi-ellipsis-v"
buttonType="main" buttonType="main"
@ -74,7 +75,12 @@
></button> ></button>
</td> </td>
<bit-menu #projectMenu> <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> <i class="bwi bwi-fw bwi-pencil" aria-hidden="true"></i>
{{ "editProject" | i18n }} {{ "editProject" | i18n }}
</button> </button>
@ -82,7 +88,7 @@
<i class="bwi bwi-fw bwi-eye" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-eye" aria-hidden="true"></i>
{{ "viewProject" | i18n }} {{ "viewProject" | i18n }}
</a> </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> <i class="bwi bwi-fw bwi-trash tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteProject" | i18n }}</span> <span class="tw-text-danger">{{ "deleteProject" | i18n }}</span>
</button> </button>

View File

@ -1,6 +1,6 @@
import { SelectionModel } from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Subject, takeUntil } from "rxjs"; import { map } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.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", selector: "sm-projects-list",
templateUrl: "./projects-list.component.html", templateUrl: "./projects-list.component.html",
}) })
export class ProjectsListComponent implements OnDestroy { export class ProjectsListComponent {
protected dataSource = new TableDataSource<ProjectListView>();
@Input() @Input()
get projects(): ProjectListView[] { get projects(): ProjectListView[] {
return this._projects; return this._projects;
@ -33,26 +31,18 @@ export class ProjectsListComponent implements OnDestroy {
@Output() editProjectEvent = new EventEmitter<string>(); @Output() editProjectEvent = new EventEmitter<string>();
@Output() deleteProjectEvent = new EventEmitter<ProjectListView[]>(); @Output() deleteProjectEvent = new EventEmitter<ProjectListView[]>();
@Output() onProjectCheckedEvent = new EventEmitter<string[]>();
@Output() newProjectEvent = new EventEmitter(); @Output() newProjectEvent = new EventEmitter();
private destroy$: Subject<void> = new Subject<void>();
selection = new SelectionModel<string>(true, []); selection = new SelectionModel<string>(true, []);
protected dataSource = new TableDataSource<ProjectListView>();
protected hasWriteAccessOnSelected$ = this.selection.changed.pipe(
map((_) => this.selectedHasWriteAccess())
);
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService 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() { isAllSelected() {
const numSelected = this.selection.selected.length; 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 { AccessSelectorComponent } from "./access-policies/access-selector.component";
import { AccessRemovalDialogComponent } from "./access-policies/dialogs/access-removal-dialog.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 { BulkStatusDialogComponent } from "./dialogs/bulk-status-dialog.component";
import { HeaderComponent } from "./header.component"; import { HeaderComponent } from "./header.component";
import { NewMenuComponent } from "./new-menu.component"; import { NewMenuComponent } from "./new-menu.component";
@ -30,6 +31,7 @@ import { SecretsListComponent } from "./secrets-list.component";
AccessRemovalDialogComponent, AccessRemovalDialogComponent,
AccessSelectorComponent, AccessSelectorComponent,
BulkStatusDialogComponent, BulkStatusDialogComponent,
BulkConfirmationDialogComponent,
HeaderComponent, HeaderComponent,
NewMenuComponent, NewMenuComponent,
ProjectsListComponent, ProjectsListComponent,
@ -40,6 +42,7 @@ import { SecretsListComponent } from "./secrets-list.component";
declarations: [ declarations: [
AccessRemovalDialogComponent, AccessRemovalDialogComponent,
BulkStatusDialogComponent, BulkStatusDialogComponent,
BulkConfirmationDialogComponent,
HeaderComponent, HeaderComponent,
NewMenuComponent, NewMenuComponent,
ProjectsListComponent, ProjectsListComponent,