1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM 22968] Enforce UI Restrictions When MSP/BUP Is Disabled (#15752)

* Suspended client change and tooltips

* Add the warning Icon properly

* Add feature flag to the changes

* Reverted it, should only show for admins

* Add changes to disable AddOrganizationButton

* change the default value

* Refactor the code to reactive

* Apply the requested changes

* Remove the providerId
This commit is contained in:
cyprain-okeke
2025-07-29 09:59:35 +01:00
committed by GitHub
parent e3d5385661
commit 95f037390e
7 changed files with 167 additions and 74 deletions

View File

@@ -1,10 +1,11 @@
@let isAdminOrServiceUser = isAdminOrServiceUser$ | async;
<app-header> <app-header>
<bit-search <bit-search
class="tw-grow" class="tw-grow"
[formControl]="searchControl" [formControl]="searchControl"
[placeholder]="'search' | i18n" [placeholder]="'search' | i18n"
></bit-search> ></bit-search>
<a bitButton routerLink="create" *ngIf="manageOrganizations" buttonType="primary"> <a bitButton routerLink="create" *ngIf="isAdminOrServiceUser" buttonType="primary">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newClient" | i18n }} {{ "newClient" | i18n }}
</a> </a>
@@ -12,7 +13,7 @@
type="button" type="button"
bitButton bitButton
(click)="addExistingOrganization()" (click)="addExistingOrganization()"
*ngIf="manageOrganizations && showAddExisting" *ngIf="isAdminOrServiceUser && showAddExisting"
> >
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addExistingOrganization" | i18n }} {{ "addExistingOrganization" | i18n }}
@@ -52,7 +53,7 @@
<span>{{ row.plan }}</span> <span>{{ row.plan }}</span>
<div appListDropdown> <div appListDropdown>
<button <button
*ngIf="manageOrganizations" *ngIf="isAdminOrServiceUser"
[bitMenuTriggerFor]="removeMenu" [bitMenuTriggerFor]="removeMenu"
bitMenuItem bitMenuItem
buttonType="secondary" buttonType="secondary"

View File

@@ -3,8 +3,8 @@ import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, from, map } from "rxjs"; import { firstValueFrom, from, map, Observable, switchMap } from "rxjs";
import { debounceTime, first, switchMap } from "rxjs/operators"; import { debounceTime, first } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -54,15 +54,29 @@ const DisallowedPlanTypes = [
], ],
}) })
export class ClientsComponent { export class ClientsComponent {
providerId: string = "";
addableOrganizations: Organization[] = []; addableOrganizations: Organization[] = [];
loading = true; loading = true;
manageOrganizations = false;
showAddExisting = false; showAddExisting = false;
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> = dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
new TableDataSource(); new TableDataSource();
protected searchControl = new FormControl("", { nonNullable: true }); protected searchControl = new FormControl("", { nonNullable: true });
protected providerId$: Observable<string> =
this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ??
new Observable();
protected provider$ = this.providerId$.pipe(
switchMap((providerId) => this.providerService.get$(providerId)),
);
protected isAdminOrServiceUser$ = this.provider$.pipe(
map(
(provider) =>
provider?.type === ProviderUserType.ProviderAdmin ||
provider?.type === ProviderUserType.ServiceUser,
),
);
constructor( constructor(
private router: Router, private router: Router,
private providerService: ProviderService, private providerService: ProviderService,
@@ -81,24 +95,17 @@ export class ClientsComponent {
this.searchControl.setValue(queryParams.search); this.searchControl.setValue(queryParams.search);
}); });
this.activatedRoute.parent?.params this.provider$
?.pipe( .pipe(
switchMap((params) => { map((provider) => {
this.providerId = params.providerId; if (provider?.providerStatus === ProviderStatusType.Billable) {
return this.providerService.get$(this.providerId).pipe( return from(
map((provider) => provider?.providerStatus === ProviderStatusType.Billable), this.router.navigate(["../manage-client-organizations"], {
map((isBillable) => { relativeTo: this.activatedRoute,
if (isBillable) { }),
return from( );
this.router.navigate(["../manage-client-organizations"], { }
relativeTo: this.activatedRoute, return from(this.load());
}),
);
} else {
return from(this.load());
}
}),
);
}), }),
takeUntilDestroyed(), takeUntilDestroyed(),
) )
@@ -124,7 +131,8 @@ export class ClientsComponent {
} }
try { try {
await this.webProviderService.detachOrganization(this.providerId, organization.id); const providerId = await firstValueFrom(this.providerId$);
await this.webProviderService.detachOrganization(providerId, organization.id);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: "", title: "",
@@ -137,12 +145,11 @@ export class ClientsComponent {
} }
async load() { async load() {
const response = await this.apiService.getProviderClients(this.providerId); const providerId = await firstValueFrom(this.providerId$);
const response = await this.apiService.getProviderClients(providerId);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const clients = response.data != null && response.data.length > 0 ? response.data : []; const clients = response.data != null && response.data.length > 0 ? response.data : [];
this.dataSource.data = clients; this.dataSource.data = clients;
this.manageOrganizations =
(await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin;
const candidateOrgs = ( const candidateOrgs = (
await firstValueFrom(this.organizationService.organizations$(userId)) await firstValueFrom(this.organizationService.organizations$(userId))
).filter((o) => o.isOwner && o.providerId == null); ).filter((o) => o.isOwner && o.providerId == null);
@@ -158,8 +165,9 @@ export class ClientsComponent {
} }
async addExistingOrganization() { async addExistingOrganization() {
const providerId = await firstValueFrom(this.providerId$);
const dialogRef = AddOrganizationComponent.open(this.dialogService, { const dialogRef = AddOrganizationComponent.open(this.dialogService, {
providerId: this.providerId, providerId: providerId,
organizations: this.addableOrganizations, organizations: this.addableOrganizations,
}); });

View File

@@ -10,7 +10,15 @@
icon="bwi-provider" icon="bwi-provider"
[text]="clientsTranslationKey$ | async | i18n" [text]="clientsTranslationKey$ | async | i18n"
[route]="(isBillable | async) ? 'manage-client-organizations' : 'clients'" [route]="(isBillable | async) ? 'manage-client-organizations' : 'clients'"
></bit-nav-item> >
<i
*ngIf="!provider.enabled && (providerPortalTakeover$ | async)"
slot="end"
class="bwi bwi-exclamation-triangle tw-text-danger"
title="{{ 'providerIsDisabled' | i18n }}"
aria-hidden="true"
></i>
</bit-nav-item>
<bit-nav-group <bit-nav-group
icon="bwi-sliders" icon="bwi-sliders"
[text]="'manage' | i18n" [text]="'manage' | i18n"

View File

@@ -38,6 +38,7 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected clientsTranslationKey$: Observable<string>; protected clientsTranslationKey$: Observable<string>;
protected managePaymentDetailsOutsideCheckout$: Observable<boolean>; protected managePaymentDetailsOutsideCheckout$: Observable<boolean>;
protected providerPortalTakeover$: Observable<boolean>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -94,6 +95,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
.subscribe(); .subscribe();
this.providerPortalTakeover$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21821_ProviderPortalTakeover,
);
} }
ngOnDestroy() { ngOnDestroy() {

View File

@@ -1,3 +1,5 @@
@let isSuspensionActive = suspensionActive$ | async;
@let provider = provider$ | async;
<app-header [title]="pageTitle"> <app-header [title]="pageTitle">
<bit-search [placeholder]="'search' | i18n" [formControl]="searchControl"></bit-search> <bit-search [placeholder]="'search' | i18n" [formControl]="searchControl"></bit-search>
<button <button
@@ -5,17 +7,31 @@
buttonType="primary" buttonType="primary"
type="button" type="button"
[bitMenuTriggerFor]="clientMenu" [bitMenuTriggerFor]="clientMenu"
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
appA11yTitle="{{ 'add' | i18n }}" appA11yTitle="{{ 'add' | i18n }}"
> >
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "add" | i18n }} {{ "add" | i18n }}
</button> </button>
<bit-menu #clientMenu> <bit-menu #clientMenu>
<button type="button" bitMenuItem (click)="createClient()"> <button
type="button"
bitMenuItem
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
(click)="createClient()"
>
<i aria-hidden="true" class="bwi bwi-business"></i> <i aria-hidden="true" class="bwi bwi-business"></i>
{{ newClientButtonLabel }} {{ newClientButtonLabel }}
</button> </button>
<button type="button" bitMenuItem (click)="addExistingOrganization()"> <button
type="button"
bitMenuItem
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
(click)="addExistingOrganization()"
>
<i aria-hidden="true" class="bwi bwi-filter"></i> <i aria-hidden="true" class="bwi bwi-filter"></i>
{{ "existingOrganization" | i18n }} {{ "existingOrganization" | i18n }}
</button> </button>
@@ -73,26 +89,41 @@
appA11yTitle="{{ 'options' | i18n }}" appA11yTitle="{{ 'options' | i18n }}"
></button> ></button>
<bit-menu #rowMenu> <bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="manageClientName(row)"> <button
type="button"
bitMenuItem
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
(click)="manageClientName(row)"
>
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> <i aria-hidden="true" class="bwi bwi-pencil-square"></i>
{{ "updateName" | i18n }} {{ "updateName" | i18n }}
</button> </button>
<button type="button" bitMenuItem (click)="manageClientSubscription(row)"> <button
type="button"
bitMenuItem
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
(click)="manageClientSubscription(row)"
>
<i aria-hidden="true" class="bwi bwi-family"></i> <i aria-hidden="true" class="bwi bwi-family"></i>
{{ "manageSubscription" | i18n }} {{ "manageSubscription" | i18n }}
</button> </button>
<button *ngIf="isProviderAdmin" type="button" bitMenuItem (click)="remove(row)"> @if (provider?.type === ProviderUserType.ProviderAdmin) {
<span class="tw-text-danger"> <button type="button" bitMenuItem (click)="remove(row)">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }} <span class="tw-text-danger">
</span> <i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
</button> </span>
</button>
}
</bit-menu> </bit-menu>
</td> </td>
</ng-template> </ng-template>
</bit-table-scroll> </bit-table-scroll>
<div *ngIf="dataSource.data.length === 0" class="tw-mt-10"> <div *ngIf="dataSource.data.length === 0" class="tw-mt-10">
<app-no-clients <app-no-clients
[showAddOrganizationButton]="isProviderAdmin" [showAddOrganizationButton]="provider?.type === ProviderUserType.ProviderAdmin"
[disableAddOrganizationButton]="isSuspensionActive"
(addNewOrganizationClicked)="createClient()" (addNewOrganizationClicked)="createClient()"
/> />
</div> </div>

View File

@@ -2,8 +2,16 @@ import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, from, lastValueFrom, map } from "rxjs"; import {
import { debounceTime, first, switchMap } from "rxjs/operators"; firstValueFrom,
from,
lastValueFrom,
map,
combineLatest,
switchMap,
Observable,
} from "rxjs";
import { debounceTime, first } from "rxjs/operators";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { import {
@@ -15,6 +23,8 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { import {
@@ -61,20 +71,49 @@ import { ReplacePipe } from "./replace.pipe";
], ],
}) })
export class ManageClientsComponent { export class ManageClientsComponent {
providerId: string = "";
provider: Provider | undefined;
loading = true; loading = true;
isProviderAdmin = false;
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> = dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
new TableDataSource(); new TableDataSource();
protected searchControl = new FormControl("", { nonNullable: true }); protected searchControl = new FormControl("", { nonNullable: true });
protected plans: PlanResponse[] = []; protected plans: PlanResponse[] = [];
protected ProviderUserType = ProviderUserType;
pageTitle = this.i18nService.t("clients"); pageTitle = this.i18nService.t("clients");
clientColumnHeader = this.i18nService.t("client"); clientColumnHeader = this.i18nService.t("client");
newClientButtonLabel = this.i18nService.t("newClient"); newClientButtonLabel = this.i18nService.t("newClient");
protected providerId$: Observable<string> =
this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ??
new Observable();
protected provider$ = this.providerId$.pipe(
switchMap((providerId) => this.providerService.get$(providerId)),
);
protected isAdminOrServiceUser$ = this.provider$.pipe(
map(
(provider) =>
provider?.type === ProviderUserType.ProviderAdmin ||
provider?.type === ProviderUserType.ServiceUser,
),
);
protected providerPortalTakeover$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21821_ProviderPortalTakeover,
);
protected suspensionActive$ = combineLatest([
this.isAdminOrServiceUser$,
this.providerPortalTakeover$,
this.provider$.pipe(map((provider) => provider?.enabled ?? false)),
]).pipe(
map(
([isAdminOrServiceUser, portalTakeoverEnabled, providerEnabled]) =>
isAdminOrServiceUser && portalTakeoverEnabled && !providerEnabled,
),
);
constructor( constructor(
private billingApiService: BillingApiServiceAbstraction, private billingApiService: BillingApiServiceAbstraction,
private providerService: ProviderService, private providerService: ProviderService,
@@ -86,29 +125,23 @@ export class ManageClientsComponent {
private validationService: ValidationService, private validationService: ValidationService,
private webProviderService: WebProviderService, private webProviderService: WebProviderService,
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
) { ) {
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search); this.searchControl.setValue(queryParams.search);
}); });
this.activatedRoute.parent?.params this.provider$
?.pipe( .pipe(
switchMap((params) => { map((provider: Provider) => {
this.providerId = params.providerId; if (provider?.providerStatus !== ProviderStatusType.Billable) {
return this.providerService.get$(this.providerId).pipe( return from(
map((provider: Provider) => provider?.providerStatus === ProviderStatusType.Billable), this.router.navigate(["../clients"], {
map((isBillable) => { relativeTo: this.activatedRoute,
if (!isBillable) { }),
return from( );
this.router.navigate(["../clients"], { }
relativeTo: this.activatedRoute, return from(this.load());
}),
);
} else {
return from(this.load());
}
}),
);
}), }),
takeUntilDestroyed(), takeUntilDestroyed(),
) )
@@ -124,15 +157,15 @@ export class ManageClientsComponent {
async load() { async load() {
try { try {
this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); const providerId = await firstValueFrom(this.providerId$);
if (this.provider?.providerType === ProviderType.BusinessUnit) { const provider = await firstValueFrom(this.providerService.get$(providerId));
if (provider?.providerType === ProviderType.BusinessUnit) {
this.pageTitle = this.i18nService.t("businessUnits"); this.pageTitle = this.i18nService.t("businessUnits");
this.clientColumnHeader = this.i18nService.t("businessUnit"); this.clientColumnHeader = this.i18nService.t("businessUnit");
this.newClientButtonLabel = this.i18nService.t("newBusinessUnit"); this.newClientButtonLabel = this.i18nService.t("newBusinessUnit");
} }
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
this.dataSource.data = ( this.dataSource.data = (
await this.billingApiService.getProviderClientOrganizations(this.providerId) await this.billingApiService.getProviderClientOrganizations(providerId)
).data; ).data;
this.plans = (await this.billingApiService.getPlans()).data; this.plans = (await this.billingApiService.getPlans()).data;
this.loading = false; this.loading = false;
@@ -142,10 +175,11 @@ export class ManageClientsComponent {
} }
addExistingOrganization = async () => { addExistingOrganization = async () => {
if (this.provider) { const provider = await firstValueFrom(this.provider$);
if (provider) {
const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, { const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, {
data: { data: {
provider: this.provider, provider: provider,
}, },
}); });
@@ -158,9 +192,10 @@ export class ManageClientsComponent {
}; };
createClient = async () => { createClient = async () => {
const providerId = await firstValueFrom(this.providerId$);
const reference = openCreateClientDialog(this.dialogService, { const reference = openCreateClientDialog(this.dialogService, {
data: { data: {
providerId: this.providerId, providerId: providerId,
plans: this.plans, plans: this.plans,
}, },
}); });
@@ -173,9 +208,10 @@ export class ManageClientsComponent {
}; };
manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => { manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => {
const providerId = await firstValueFrom(this.providerId$);
const dialogRef = openManageClientNameDialog(this.dialogService, { const dialogRef = openManageClientNameDialog(this.dialogService, {
data: { data: {
providerId: this.providerId, providerId: providerId,
organization: { organization: {
id: organization.id, id: organization.id,
name: organization.organizationName, name: organization.organizationName,
@@ -194,10 +230,11 @@ export class ManageClientsComponent {
manageClientSubscription = async ( manageClientSubscription = async (
organization: ProviderOrganizationOrganizationDetailsResponse, organization: ProviderOrganizationOrganizationDetailsResponse,
) => { ) => {
const provider = await firstValueFrom(this.provider$);
const dialogRef = openManageClientSubscriptionDialog(this.dialogService, { const dialogRef = openManageClientSubscriptionDialog(this.dialogService, {
data: { data: {
organization, organization,
provider: this.provider!, provider: provider!,
}, },
}); });
@@ -220,7 +257,8 @@ export class ManageClientsComponent {
} }
try { try {
await this.webProviderService.detachOrganization(this.providerId, organization.id); const providerId = await firstValueFrom(this.providerId$);
await this.webProviderService.detachOrganization(providerId, organization.id);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: "", title: "",

View File

@@ -30,6 +30,7 @@ const gearIcon = svgIcon`
<p class="tw-mt-4">{{ "noClients" | i18n }}</p> <p class="tw-mt-4">{{ "noClients" | i18n }}</p>
<a <a
*ngIf="showAddOrganizationButton" *ngIf="showAddOrganizationButton"
[disabled]="disableAddOrganizationButton"
type="button" type="button"
bitButton bitButton
buttonType="primary" buttonType="primary"
@@ -43,6 +44,7 @@ const gearIcon = svgIcon`
export class NoClientsComponent { export class NoClientsComponent {
icon = gearIcon; icon = gearIcon;
@Input() showAddOrganizationButton = true; @Input() showAddOrganizationButton = true;
@Input() disableAddOrganizationButton = false;
@Output() addNewOrganizationClicked = new EventEmitter(); @Output() addNewOrganizationClicked = new EventEmitter();
addNewOrganization = () => this.addNewOrganizationClicked.emit(); addNewOrganization = () => this.addNewOrganizationClicked.emit();