[AC-1911] Clients: Create components to manage client organization seat allocation (#8505)
* implementing the clients changes * resolve pr comments on message.json * moved the method to billing-api.service * move the request and response files to billing folder * remove the adding existing orgs * resolve the routing issue * resolving the pr comments * code owner changes * fix the assignedseat * resolve the warning message * resolve the error on update * passing the right id * resolve the unassign value * removed unused logservice * Adding the loader on submit button
This commit is contained in:
parent
b9771c1e42
commit
9956f020e7
|
@ -61,6 +61,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev
|
||||||
libs/angular/src/billing @bitwarden/team-billing-dev
|
libs/angular/src/billing @bitwarden/team-billing-dev
|
||||||
libs/common/src/billing @bitwarden/team-billing-dev
|
libs/common/src/billing @bitwarden/team-billing-dev
|
||||||
libs/billing @bitwarden/team-billing-dev
|
libs/billing @bitwarden/team-billing-dev
|
||||||
|
bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev
|
||||||
|
|
||||||
## Platform team files ##
|
## Platform team files ##
|
||||||
apps/browser/src/platform @bitwarden/team-platform-dev
|
apps/browser/src/platform @bitwarden/team-platform-dev
|
||||||
|
|
|
@ -4956,6 +4956,9 @@
|
||||||
"addExistingOrganization": {
|
"addExistingOrganization": {
|
||||||
"message": "Add existing organization"
|
"message": "Add existing organization"
|
||||||
},
|
},
|
||||||
|
"addNewOrganization": {
|
||||||
|
"message": "Add new organization"
|
||||||
|
},
|
||||||
"myProvider": {
|
"myProvider": {
|
||||||
"message": "My Provider"
|
"message": "My Provider"
|
||||||
},
|
},
|
||||||
|
@ -7642,5 +7645,38 @@
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"message": "Items"
|
"message": "Items"
|
||||||
|
},
|
||||||
|
"assignedSeats": {
|
||||||
|
"message": "Assigned seats"
|
||||||
|
},
|
||||||
|
"assigned": {
|
||||||
|
"message": "Assigned"
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"message": "Used"
|
||||||
|
},
|
||||||
|
"remaining": {
|
||||||
|
"message": "Remaining"
|
||||||
|
},
|
||||||
|
"unlinkOrganization": {
|
||||||
|
"message": "Unlink organization"
|
||||||
|
},
|
||||||
|
"manageSeats": {
|
||||||
|
"message": "MANAGE SEATS"
|
||||||
|
},
|
||||||
|
"manageSeatsDescription": {
|
||||||
|
"message": "Adjustments to seats will be reflected in the next billing cycle."
|
||||||
|
},
|
||||||
|
"unassignedSeatsDescription": {
|
||||||
|
"message": "Unassigned subscription seats"
|
||||||
|
},
|
||||||
|
"purchaseSeatDescription": {
|
||||||
|
"message": "Additional seats purchased"
|
||||||
|
},
|
||||||
|
"assignedSeatCannotUpdate": {
|
||||||
|
"message": "Assigned Seats can not be updated. Please contact your organization owner for assistance."
|
||||||
|
},
|
||||||
|
"subscriptionUpdateFailed": {
|
||||||
|
"message": "Subscription update failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, OnInit } from "@angular/core";
|
import { Component, OnInit } from "@angular/core";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { first } from "rxjs/operators";
|
import { first } from "rxjs/operators";
|
||||||
|
|
||||||
|
@ -13,6 +13,8 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
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 { PlanType } from "@bitwarden/common/billing/enums";
|
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||||
|
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
@ -50,8 +52,14 @@ export class ClientsComponent implements OnInit {
|
||||||
protected actionPromise: Promise<unknown>;
|
protected actionPromise: Promise<unknown>;
|
||||||
private pagedClientsCount = 0;
|
private pagedClientsCount = 0;
|
||||||
|
|
||||||
|
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.EnableConsolidatedBilling,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private providerService: ProviderService,
|
private providerService: ProviderService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
|
@ -64,20 +72,29 @@ export class ClientsComponent implements OnInit {
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||||
this.route.parent.params.subscribe(async (params) => {
|
|
||||||
this.providerId = params.providerId;
|
|
||||||
|
|
||||||
await this.load();
|
const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$);
|
||||||
|
|
||||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
if (enableConsolidatedBilling) {
|
||||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route });
|
||||||
this.searchText = qParams.search;
|
} else {
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||||
|
this.route.parent.params.subscribe(async (params) => {
|
||||||
|
this.providerId = params.providerId;
|
||||||
|
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||||
|
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||||
|
this.searchText = qParams.search;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
|
|
@ -4,7 +4,11 @@
|
||||||
<bit-icon [icon]="logo"></bit-icon>
|
<bit-icon [icon]="logo"></bit-icon>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<bit-nav-item icon="bwi-bank" [text]="'clients' | i18n" route="clients"></bit-nav-item>
|
<bit-nav-item
|
||||||
|
icon="bwi-bank"
|
||||||
|
[text]="'clients' | i18n"
|
||||||
|
[route]="(enableConsolidatedBilling$ | async) ? 'manage-client-organizations' : 'clients'"
|
||||||
|
></bit-nav-item>
|
||||||
<bit-nav-group icon="bwi-sliders" [text]="'manage' | i18n" route="manage" *ngIf="showManageTab">
|
<bit-nav-group icon="bwi-sliders" [text]="'manage' | i18n" route="manage" *ngIf="showManageTab">
|
||||||
<bit-nav-item
|
<bit-nav-item
|
||||||
[text]="'people' | i18n"
|
[text]="'people' | i18n"
|
||||||
|
|
|
@ -37,6 +37,11 @@ export class ProvidersLayoutComponent {
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.EnableConsolidatedBilling,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private providerService: ProviderService,
|
private providerService: ProviderService,
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { ProvidersComponent } from "@bitwarden/web-vault/app/admin-console/provi
|
||||||
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
|
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
|
||||||
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
||||||
|
|
||||||
|
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients/manage-client-organizations.component";
|
||||||
|
|
||||||
import { ClientsComponent } from "./clients/clients.component";
|
import { ClientsComponent } from "./clients/clients.component";
|
||||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||||
import { ProviderPermissionsGuard } from "./guards/provider-permissions.guard";
|
import { ProviderPermissionsGuard } from "./guards/provider-permissions.guard";
|
||||||
|
@ -64,6 +66,11 @@ const routes: Routes = [
|
||||||
{ path: "", pathMatch: "full", redirectTo: "clients" },
|
{ path: "", pathMatch: "full", redirectTo: "clients" },
|
||||||
{ path: "clients/create", component: CreateOrganizationComponent },
|
{ path: "clients/create", component: CreateOrganizationComponent },
|
||||||
{ path: "clients", component: ClientsComponent, data: { titleId: "clients" } },
|
{ path: "clients", component: ClientsComponent, data: { titleId: "clients" } },
|
||||||
|
{
|
||||||
|
path: "manage-client-organizations",
|
||||||
|
component: ManageClientOrganizationsComponent,
|
||||||
|
data: { titleId: "manage-client-organizations" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "manage",
|
path: "manage",
|
||||||
children: [
|
children: [
|
||||||
|
|
|
@ -8,6 +8,9 @@ import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
|
||||||
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
|
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
|
||||||
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
||||||
|
|
||||||
|
import { ManageClientOrganizationSubscriptionComponent } from "../../billing/providers/clients/manage-client-organization-subscription.component";
|
||||||
|
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients/manage-client-organizations.component";
|
||||||
|
|
||||||
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
||||||
import { ClientsComponent } from "./clients/clients.component";
|
import { ClientsComponent } from "./clients/clients.component";
|
||||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||||
|
@ -50,6 +53,8 @@ import { SetupComponent } from "./setup/setup.component";
|
||||||
SetupComponent,
|
SetupComponent,
|
||||||
SetupProviderComponent,
|
SetupProviderComponent,
|
||||||
UserAddEditComponent,
|
UserAddEditComponent,
|
||||||
|
ManageClientOrganizationsComponent,
|
||||||
|
ManageClientOrganizationSubscriptionComponent,
|
||||||
],
|
],
|
||||||
providers: [WebProviderService, ProviderPermissionsGuard],
|
providers: [WebProviderService, ProviderPermissionsGuard],
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
<bit-dialog dialogSize="large" [loading]="loading">
|
||||||
|
<span bitDialogTitle>
|
||||||
|
{{ "manageSeats" | i18n }}
|
||||||
|
<small class="tw-text-muted" *ngIf="clientName">{{ clientName }}</small>
|
||||||
|
</span>
|
||||||
|
<div bitDialogContent>
|
||||||
|
<p>
|
||||||
|
{{ "manageSeatsDescription" | i18n }}
|
||||||
|
</p>
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>
|
||||||
|
{{ "assignedSeats" | i18n }}
|
||||||
|
</bit-label>
|
||||||
|
<input
|
||||||
|
id="assignedSeats"
|
||||||
|
type="number"
|
||||||
|
appAutoFocus
|
||||||
|
bitInput
|
||||||
|
required
|
||||||
|
[(ngModel)]="assignedSeats"
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
<ng-container *ngIf="remainingOpenSeats > 0">
|
||||||
|
<p>
|
||||||
|
<small class="tw-text-muted">{{ unassignedSeats }}</small>
|
||||||
|
<small class="tw-text-muted">{{ "unassignedSeatsDescription" | i18n }}</small>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<small class="tw-text-muted">{{ AdditionalSeatPurchased }}</small>
|
||||||
|
<small class="tw-text-muted">{{ "purchaseSeatDescription" | i18n }}</small>
|
||||||
|
</p>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
bitFormButton
|
||||||
|
(click)="updateSubscription(assignedSeats)"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-refresh bwi-fw" aria-hidden="true"></i>
|
||||||
|
{{ "save" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||||
|
{{ "cancel" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</bit-dialog>
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||||
|
import { Component, Inject, OnInit } from "@angular/core";
|
||||||
|
|
||||||
|
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||||
|
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||||
|
import { ProviderSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/provider-subscription-update.request";
|
||||||
|
import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
type ManageClientOrganizationDialogParams = {
|
||||||
|
organization: ProviderOrganizationOrganizationDetailsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: "manage-client-organization-subscription.component.html",
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
|
export class ManageClientOrganizationSubscriptionComponent implements OnInit {
|
||||||
|
loading = true;
|
||||||
|
providerOrganizationId: string;
|
||||||
|
providerId: string;
|
||||||
|
|
||||||
|
clientName: string;
|
||||||
|
assignedSeats: number;
|
||||||
|
unassignedSeats: number;
|
||||||
|
planName: string;
|
||||||
|
AdditionalSeatPurchased: number;
|
||||||
|
remainingOpenSeats: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: DialogRef,
|
||||||
|
@Inject(DIALOG_DATA) protected data: ManageClientOrganizationDialogParams,
|
||||||
|
private billingApiService: BillingApiService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private platformUtilsService: PlatformUtilsService,
|
||||||
|
) {
|
||||||
|
this.providerOrganizationId = data.organization.id;
|
||||||
|
this.providerId = data.organization.providerId;
|
||||||
|
this.clientName = data.organization.organizationName;
|
||||||
|
this.assignedSeats = data.organization.seats;
|
||||||
|
this.planName = data.organization.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
try {
|
||||||
|
const response = await this.billingApiService.getProviderClientSubscriptions(this.providerId);
|
||||||
|
this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans);
|
||||||
|
const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans);
|
||||||
|
const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans);
|
||||||
|
this.remainingOpenSeats = seatMinimum - assignedByPlan;
|
||||||
|
this.unassignedSeats = Math.abs(this.remainingOpenSeats);
|
||||||
|
} catch (error) {
|
||||||
|
this.remainingOpenSeats = 0;
|
||||||
|
this.AdditionalSeatPurchased = 0;
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscription(assignedSeats: number) {
|
||||||
|
this.loading = true;
|
||||||
|
if (!assignedSeats) {
|
||||||
|
this.platformUtilsService.showToast(
|
||||||
|
"error",
|
||||||
|
null,
|
||||||
|
this.i18nService.t("assignedSeatCannotUpdate"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = new ProviderSubscriptionUpdateRequest();
|
||||||
|
request.assignedSeats = assignedSeats;
|
||||||
|
|
||||||
|
await this.billingApiService.putProviderClientSubscriptions(
|
||||||
|
this.providerId,
|
||||||
|
this.providerOrganizationId,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated"));
|
||||||
|
this.loading = false;
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPurchasedSeatsByPlan(planName: string, plans: Plans[]): number {
|
||||||
|
const plan = plans.find((plan) => plan.planName === planName);
|
||||||
|
if (plan) {
|
||||||
|
return plan.purchasedSeats;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssignedByPlan(planName: string, plans: Plans[]): number {
|
||||||
|
const plan = plans.find((plan) => plan.planName === planName);
|
||||||
|
if (plan) {
|
||||||
|
return plan.assignedSeats;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderSeatMinimumByPlan(planName: string, plans: Plans[]) {
|
||||||
|
const plan = plans.find((plan) => plan.planName === planName);
|
||||||
|
if (plan) {
|
||||||
|
return plan.seatMinimum;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static open(dialogService: DialogService, data: ManageClientOrganizationDialogParams) {
|
||||||
|
return dialogService.open(ManageClientOrganizationSubscriptionComponent, { data });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
<app-header>
|
||||||
|
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
|
||||||
|
<a bitButton routerLink="create" *ngIf="manageOrganizations" buttonType="primary">
|
||||||
|
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||||
|
{{ "addNewOrganization" | i18n }}
|
||||||
|
</a>
|
||||||
|
</app-header>
|
||||||
|
|
||||||
|
<ng-container *ngIf="loading">
|
||||||
|
<i
|
||||||
|
class="bwi bwi-spinner bwi-spin text-muted"
|
||||||
|
title="{{ 'loading' | i18n }}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container
|
||||||
|
*ngIf="!loading && (clients | search: searchText : 'organizationName' : 'id') as searchedClients"
|
||||||
|
>
|
||||||
|
<p *ngIf="!searchedClients.length">{{ "noClientsInList" | i18n }}</p>
|
||||||
|
<ng-container *ngIf="searchedClients.length">
|
||||||
|
<bit-table
|
||||||
|
*ngIf="searchedClients?.length >= 1"
|
||||||
|
[dataSource]="dataSource"
|
||||||
|
class="table table-hover table-list"
|
||||||
|
infiniteScroll
|
||||||
|
[infiniteScrollDistance]="1"
|
||||||
|
[infiniteScrollDisabled]="!isPaging()"
|
||||||
|
(scrolled)="loadMore()"
|
||||||
|
>
|
||||||
|
<ng-container header>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" bitCell bitSortable="organizationName" default>{{ "client" | i18n }}</th>
|
||||||
|
<th bitCell bitSortable="seats">{{ "assigned" | i18n }}</th>
|
||||||
|
<th bitCell bitSortable="userCount">{{ "used" | i18n }}</th>
|
||||||
|
<th bitCell bitSortable="userCount">{{ "remaining" | i18n }}</th>
|
||||||
|
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template body let-rows$>
|
||||||
|
<tr bitRow *ngFor="let client of rows$ | async">
|
||||||
|
<td bitCell width="30">
|
||||||
|
<bit-avatar [text]="client.organizationName" [id]="client.id" size="small"></bit-avatar>
|
||||||
|
</td>
|
||||||
|
<td bitCell>
|
||||||
|
<div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
|
||||||
|
<a bitLink [routerLink]="['/organizations', client.organizationId]">{{
|
||||||
|
client.organizationName
|
||||||
|
}}</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td bitCell class="tw-whitespace-nowrap">
|
||||||
|
<span>{{ client.seats }}</span>
|
||||||
|
</td>
|
||||||
|
<td bitCell class="tw-whitespace-nowrap">
|
||||||
|
<span>{{ client.userCount }}</span>
|
||||||
|
</td>
|
||||||
|
<td bitCell class="tw-whitespace-nowrap">
|
||||||
|
<span>{{ client.seats - client.userCount }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ client.plan }}</span>
|
||||||
|
</td>
|
||||||
|
<td bitCell>
|
||||||
|
<button
|
||||||
|
[bitMenuTriggerFor]="rowMenu"
|
||||||
|
type="button"
|
||||||
|
bitIconButton="bwi-ellipsis-v"
|
||||||
|
size="small"
|
||||||
|
appA11yTitle="{{ 'options' | i18n }}"
|
||||||
|
></button>
|
||||||
|
<bit-menu #rowMenu>
|
||||||
|
<button type="button" bitMenuItem (click)="manageSubscription(client)">
|
||||||
|
<i aria-hidden="true" class="bwi bwi-question-circle"></i>
|
||||||
|
{{ "manageSubscription" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button type="button" bitMenuItem (click)="remove(client)">
|
||||||
|
<span class="tw-text-danger">
|
||||||
|
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</bit-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</bit-table>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { SelectionModel } from "@angular/cdk/collections";
|
||||||
|
import { Component, OnInit } from "@angular/core";
|
||||||
|
import { ActivatedRoute } from "@angular/router";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import { first } from "rxjs/operators";
|
||||||
|
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||||
|
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||||
|
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||||
|
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 { DialogService, TableDataSource } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||||
|
|
||||||
|
import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: "manage-client-organizations.component.html",
|
||||||
|
})
|
||||||
|
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
|
export class ManageClientOrganizationsComponent implements OnInit {
|
||||||
|
providerId: string;
|
||||||
|
loading = true;
|
||||||
|
manageOrganizations = false;
|
||||||
|
|
||||||
|
set searchText(search: string) {
|
||||||
|
this.selection.clear();
|
||||||
|
this.dataSource.filter = search;
|
||||||
|
}
|
||||||
|
|
||||||
|
clients: ProviderOrganizationOrganizationDetailsResponse[];
|
||||||
|
pagedClients: ProviderOrganizationOrganizationDetailsResponse[];
|
||||||
|
|
||||||
|
protected didScroll = false;
|
||||||
|
protected pageSize = 100;
|
||||||
|
protected actionPromise: Promise<unknown>;
|
||||||
|
private pagedClientsCount = 0;
|
||||||
|
selection = new SelectionModel<string>(true, []);
|
||||||
|
protected dataSource = new TableDataSource<ProviderOrganizationOrganizationDetailsResponse>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private providerService: ProviderService,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private searchService: SearchService,
|
||||||
|
private platformUtilsService: PlatformUtilsService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private validationService: ValidationService,
|
||||||
|
private webProviderService: WebProviderService,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||||
|
this.route.parent.params.subscribe(async (params) => {
|
||||||
|
this.providerId = params.providerId;
|
||||||
|
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||||
|
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||||
|
this.searchText = qParams.search;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const response = await this.apiService.getProviderClients(this.providerId);
|
||||||
|
this.clients = response.data != null && response.data.length > 0 ? response.data : [];
|
||||||
|
this.dataSource.data = this.clients;
|
||||||
|
this.manageOrganizations =
|
||||||
|
(await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin;
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPaging() {
|
||||||
|
const searching = this.isSearching();
|
||||||
|
if (searching && this.didScroll) {
|
||||||
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
this.resetPaging();
|
||||||
|
}
|
||||||
|
return !searching && this.clients && this.clients.length > this.pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching() {
|
||||||
|
return this.searchService.isSearchable(this.searchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPaging() {
|
||||||
|
this.pagedClients = [];
|
||||||
|
this.loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore() {
|
||||||
|
if (!this.clients || this.clients.length <= this.pageSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pagedLength = this.pagedClients.length;
|
||||||
|
let pagedSize = this.pageSize;
|
||||||
|
if (pagedLength === 0 && this.pagedClientsCount > this.pageSize) {
|
||||||
|
pagedSize = this.pagedClientsCount;
|
||||||
|
}
|
||||||
|
if (this.clients.length > pagedLength) {
|
||||||
|
this.pagedClients = this.pagedClients.concat(
|
||||||
|
this.clients.slice(pagedLength, pagedLength + pagedSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.pagedClientsCount = this.pagedClients.length;
|
||||||
|
this.didScroll = this.pagedClients.length > this.pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||||
|
if (organization == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, {
|
||||||
|
organization: organization,
|
||||||
|
});
|
||||||
|
|
||||||
|
await firstValueFrom(dialogRef.closed);
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||||
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
|
title: organization.organizationName,
|
||||||
|
content: { key: "detachOrganizationConfirmation" },
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actionPromise = this.webProviderService.detachOrganization(
|
||||||
|
this.providerId,
|
||||||
|
organization.id,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.actionPromise;
|
||||||
|
this.platformUtilsService.showToast(
|
||||||
|
"success",
|
||||||
|
null,
|
||||||
|
this.i18nService.t("detachedOrganization", organization.organizationName),
|
||||||
|
);
|
||||||
|
await this.load();
|
||||||
|
} catch (e) {
|
||||||
|
this.validationService.showError(e);
|
||||||
|
}
|
||||||
|
this.actionPromise = null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||||
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
||||||
|
import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request";
|
||||||
|
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||||
|
|
||||||
export abstract class BillingApiServiceAbstraction {
|
export abstract class BillingApiServiceAbstraction {
|
||||||
cancelOrganizationSubscription: (
|
cancelOrganizationSubscription: (
|
||||||
|
@ -8,4 +10,10 @@ export abstract class BillingApiServiceAbstraction {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
|
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
|
||||||
getBillingStatus: (id: string) => Promise<OrganizationBillingStatusResponse>;
|
getBillingStatus: (id: string) => Promise<OrganizationBillingStatusResponse>;
|
||||||
|
getProviderClientSubscriptions: (providerId: string) => Promise<ProviderSubscriptionResponse>;
|
||||||
|
putProviderClientSubscriptions: (
|
||||||
|
providerId: string,
|
||||||
|
organizationId: string,
|
||||||
|
request: ProviderSubscriptionUpdateRequest,
|
||||||
|
) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class ProviderSubscriptionUpdateRequest {
|
||||||
|
assignedSeats: number;
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { BaseResponse } from "../../../models/response/base.response";
|
||||||
|
|
||||||
|
export class ProviderSubscriptionResponse extends BaseResponse {
|
||||||
|
status: string;
|
||||||
|
currentPeriodEndDate: Date;
|
||||||
|
discountPercentage?: number | null;
|
||||||
|
plans: Plans[] = [];
|
||||||
|
|
||||||
|
constructor(response: any) {
|
||||||
|
super(response);
|
||||||
|
this.status = this.getResponseProperty("status");
|
||||||
|
this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate"));
|
||||||
|
this.discountPercentage = this.getResponseProperty("discountPercentage");
|
||||||
|
const plans = this.getResponseProperty("plans");
|
||||||
|
if (plans != null) {
|
||||||
|
this.plans = plans.map((i: any) => new Plans(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Plans extends BaseResponse {
|
||||||
|
planName: string;
|
||||||
|
seatMinimum: number;
|
||||||
|
assignedSeats: number;
|
||||||
|
purchasedSeats: number;
|
||||||
|
cost: number;
|
||||||
|
cadence: string;
|
||||||
|
|
||||||
|
constructor(response: any) {
|
||||||
|
super(response);
|
||||||
|
this.planName = this.getResponseProperty("PlanName");
|
||||||
|
this.seatMinimum = this.getResponseProperty("SeatMinimum");
|
||||||
|
this.assignedSeats = this.getResponseProperty("AssignedSeats");
|
||||||
|
this.purchasedSeats = this.getResponseProperty("PurchasedSeats");
|
||||||
|
this.cost = this.getResponseProperty("Cost");
|
||||||
|
this.cadence = this.getResponseProperty("Cadence");
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,8 @@ import { ApiService } from "../../abstractions/api.service";
|
||||||
import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction";
|
import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction";
|
||||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||||
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
||||||
|
import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request";
|
||||||
|
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||||
|
|
||||||
export class BillingApiService implements BillingApiServiceAbstraction {
|
export class BillingApiService implements BillingApiServiceAbstraction {
|
||||||
constructor(private apiService: ApiService) {}
|
constructor(private apiService: ApiService) {}
|
||||||
|
@ -34,4 +36,29 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||||
|
|
||||||
return new OrganizationBillingStatusResponse(r);
|
return new OrganizationBillingStatusResponse(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProviderClientSubscriptions(providerId: string): Promise<ProviderSubscriptionResponse> {
|
||||||
|
const r = await this.apiService.send(
|
||||||
|
"GET",
|
||||||
|
"/providers/" + providerId + "/billing/subscription",
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return new ProviderSubscriptionResponse(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
async putProviderClientSubscriptions(
|
||||||
|
providerId: string,
|
||||||
|
organizationId: string,
|
||||||
|
request: ProviderSubscriptionUpdateRequest,
|
||||||
|
): Promise<any> {
|
||||||
|
return await this.apiService.send(
|
||||||
|
"PUT",
|
||||||
|
"/providers/" + providerId + "/organizations/" + organizationId,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ export enum FeatureFlag {
|
||||||
KeyRotationImprovements = "key-rotation-improvements",
|
KeyRotationImprovements = "key-rotation-improvements",
|
||||||
FlexibleCollectionsMigration = "flexible-collections-migration",
|
FlexibleCollectionsMigration = "flexible-collections-migration",
|
||||||
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
|
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
|
||||||
|
EnableConsolidatedBilling = "enable-consolidated-billing",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace this with a type safe lookup of the feature flag values in PM-2282
|
// Replace this with a type safe lookup of the feature flag values in PM-2282
|
||||||
|
|
Loading…
Reference in New Issue