diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts index 958fe0d48e..18b6994459 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts @@ -58,3 +58,12 @@ export class ServiceAccountPeopleAccessPoliciesView { userAccessPolicies: UserServiceAccountAccessPolicyView[]; groupAccessPolicies: GroupServiceAccountAccessPolicyView[]; } + +export class ServiceAccountProjectPolicyPermissionDetailsView { + accessPolicy: ServiceAccountProjectAccessPolicyView; + hasPermission: boolean; +} + +export class ServiceAccountGrantedPoliciesView { + grantedProjectPolicies: ServiceAccountProjectPolicyPermissionDetailsView[]; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html index b97c5ef114..623542bd33 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html @@ -1,17 +1,27 @@ -
-

- {{ "machineAccountProjectsDescription" | i18n }} -

- - -
+
+
+

+ {{ "machineAccountProjectsDescription" | i18n }} +

+ + + +
+
+ + +
+ +
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts index 2fcc10988d..a6f3d720b7 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts @@ -1,90 +1,68 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatestWith, map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs"; +import { combineLatest, Subject, switchMap, takeUntil } from "rxjs"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; -import { ServiceAccountProjectAccessPolicyView } from "../../models/view/access-policy.view"; -import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +import { ServiceAccountGrantedPoliciesView } from "../../models/view/access-policy.view"; import { - AccessSelectorComponent, - AccessSelectorRowView, -} from "../../shared/access-policies/access-selector.component"; + ApItemValueType, + convertToServiceAccountGrantedPoliciesView, +} from "../../shared/access-policies/access-policy-selector/models/ap-item-value.type"; +import { + ApItemViewType, + convertPotentialGranteesToApItemViewType, + convertGrantedPoliciesToAccessPolicyItemViews, +} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type"; +import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; @Component({ selector: "sm-service-account-projects", templateUrl: "./service-account-projects.component.html", }) export class ServiceAccountProjectsComponent implements OnInit, OnDestroy { + private currentAccessPolicies: ApItemViewType[]; private destroy$ = new Subject(); - private serviceAccountId: string; private organizationId: string; + private serviceAccountId: string; - protected rows$: Observable = - this.accessPolicyService.serviceAccountGrantedPolicyChanges$.pipe( - startWith(null), - combineLatestWith(this.route.params), - switchMap(([_, params]) => - this.accessPolicyService.getGrantedPolicies(params.serviceAccountId, params.organizationId), - ), - map((policies) => { - return policies.map((policy) => { - return { - type: "project", - name: policy.grantedProjectName, - id: policy.grantedProjectId, - accessPolicyId: policy.id, - read: policy.read, - write: policy.write, - icon: AccessSelectorComponent.projectIcon, - static: false, - } as AccessSelectorRowView; - }); - }), - ); + private currentAccessPolicies$ = combineLatest([this.route.params]).pipe( + switchMap(([params]) => + this.accessPolicyService + .getServiceAccountGrantedPolicies(params.organizationId, params.serviceAccountId) + .then((policies) => { + return convertGrantedPoliciesToAccessPolicyItemViews(policies); + }), + ), + ); - protected handleCreateAccessPolicies(selected: SelectItemView[]) { - const serviceAccountProjectAccessPolicyView = selected - .filter((selection) => AccessSelectorComponent.getAccessItemType(selection) === "project") - .map((filtered) => { - const view = new ServiceAccountProjectAccessPolicyView(); - view.serviceAccountId = this.serviceAccountId; - view.grantedProjectId = filtered.id; - view.read = true; - view.write = false; - return view; - }); + private potentialGrantees$ = combineLatest([this.route.params]).pipe( + switchMap(([params]) => + this.accessPolicyService + .getProjectsPotentialGrantees(params.organizationId) + .then((grantees) => { + return convertPotentialGranteesToApItemViewType(grantees); + }), + ), + ); - return this.accessPolicyService.createGrantedPolicies( - this.organizationId, - this.serviceAccountId, - serviceAccountProjectAccessPolicyView, - ); - } + protected formGroup = new FormGroup({ + accessPolicies: new FormControl([] as ApItemValueType[]), + }); - protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) { - try { - return await this.accessPolicyService.updateAccessPolicy( - AccessSelectorComponent.getBaseAccessPolicyView(policy), - ); - } catch (e) { - this.validationService.showError(e); - } - } - - protected async handleDeleteAccessPolicy(policy: AccessSelectorRowView) { - try { - await this.accessPolicyService.deleteAccessPolicy(policy.accessPolicyId); - } catch (e) { - this.validationService.showError(e); - } - } + protected loading = true; + protected potentialGrantees: ApItemViewType[]; constructor( private route: ActivatedRoute, + private changeDetectorRef: ChangeDetectorRef, private validationService: ValidationService, private accessPolicyService: AccessPolicyService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, ) {} ngOnInit(): void { @@ -92,10 +70,119 @@ export class ServiceAccountProjectsComponent implements OnInit, OnDestroy { this.organizationId = params.organizationId; this.serviceAccountId = params.serviceAccountId; }); + + combineLatest([this.potentialGrantees$, this.currentAccessPolicies$]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([potentialGrantees, currentAccessPolicies]) => { + this.potentialGrantees = this.getPotentialGrantees( + potentialGrantees, + currentAccessPolicies, + ); + this.setSelected(currentAccessPolicies); + }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } + + submit = async () => { + if (this.isFormInvalid()) { + return; + } + const formValues = this.getFormValues(); + this.formGroup.disable(); + + try { + const grantedViews = await this.updateServiceAccountGrantedPolicies( + this.organizationId, + this.serviceAccountId, + formValues, + ); + + this.currentAccessPolicies = convertGrantedPoliciesToAccessPolicyItemViews(grantedViews); + + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("serviceAccountAccessUpdated"), + ); + } catch (e) { + this.validationService.showError(e); + this.setSelected(this.currentAccessPolicies); + } + this.formGroup.enable(); + }; + + private setSelected(policiesToSelect: ApItemViewType[]) { + this.loading = true; + this.currentAccessPolicies = policiesToSelect; + if (policiesToSelect != undefined) { + // Must detect changes so that AccessSelector @Inputs() are aware of the latest + // potentialGrantees, otherwise no selected values will be patched below + this.changeDetectorRef.detectChanges(); + this.formGroup.patchValue({ + accessPolicies: policiesToSelect.map((m) => ({ + type: m.type, + id: m.id, + permission: m.permission, + readOnly: m.readOnly, + })), + }); + } + this.loading = false; + } + + private isFormInvalid(): boolean { + this.formGroup.markAllAsTouched(); + return this.formGroup.invalid; + } + + private async updateServiceAccountGrantedPolicies( + organizationId: string, + serviceAccountId: string, + selectedPolicies: ApItemValueType[], + ): Promise { + const grantedViews = convertToServiceAccountGrantedPoliciesView( + serviceAccountId, + selectedPolicies, + ); + return await this.accessPolicyService.putServiceAccountGrantedPolicies( + organizationId, + serviceAccountId, + grantedViews, + ); + } + + private getPotentialGrantees( + potentialGrantees: ApItemViewType[], + currentAccessPolicies: ApItemViewType[], + ) { + // If the user doesn't have access to the project, they won't be in the potentialGrantees list. + // Add them to the potentialGrantees list so they can be selected as read-only. + for (const policy of currentAccessPolicies) { + const exists = potentialGrantees.some((grantee) => grantee.id === policy.id); + if (!exists) { + potentialGrantees.push(policy); + } + } + return potentialGrantees; + } + + private getFormValues(): ApItemValueType[] { + // The read-only disabled form values are not included in the formGroup value. + // Manually add them to the returned result to ensure they are included in the form submission. + let formValues = this.formGroup.value.accessPolicies; + formValues = formValues.concat( + this.currentAccessPolicies + .filter((m) => m.readOnly) + .map((m) => ({ + id: m.id, + type: m.type, + permission: m.permission, + })), + ); + return formValues; + } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html index e1faf2a185..e926ba6a13 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html @@ -29,14 +29,17 @@ bitRow *ngFor="let item of selectionList.selectedItems; let i = index" [formGroupName]="i" + [ngClass]="{ 'tw-text-muted': item.readOnly }" > - + + + + {{ item.labelName }} - {{ item.labelName }} + + +
+ {{ item.permission | i18n }} +
+
+
{{ staticPermission | i18n }}