mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-21 16:18:28 +01:00
[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
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -61,6 +61,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev
|
||||
libs/angular/src/billing @bitwarden/team-billing-dev
|
||||
libs/common/src/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 ##
|
||||
apps/browser/src/platform @bitwarden/team-platform-dev
|
||||
|
@ -4956,6 +4956,9 @@
|
||||
"addExistingOrganization": {
|
||||
"message": "Add existing organization"
|
||||
},
|
||||
"addNewOrganization": {
|
||||
"message": "Add new organization"
|
||||
},
|
||||
"myProvider": {
|
||||
"message": "My Provider"
|
||||
},
|
||||
@ -7642,5 +7645,38 @@
|
||||
},
|
||||
"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 { ActivatedRoute } from "@angular/router";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
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 { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@ -50,8 +52,14 @@ export class ClientsComponent implements OnInit {
|
||||
protected actionPromise: Promise<unknown>;
|
||||
private pagedClientsCount = 0;
|
||||
|
||||
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.EnableConsolidatedBilling,
|
||||
false,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private providerService: ProviderService,
|
||||
private apiService: ApiService,
|
||||
private searchService: SearchService,
|
||||
@ -64,20 +72,29 @@ export class ClientsComponent implements OnInit {
|
||||
private organizationService: OrganizationService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
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();
|
||||
const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$);
|
||||
|
||||
/* 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;
|
||||
if (enableConsolidatedBilling) {
|
||||
await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route });
|
||||
} 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() {
|
||||
|
@ -4,7 +4,11 @@
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</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-item
|
||||
[text]="'people' | i18n"
|
||||
|
@ -37,6 +37,11 @@ export class ProvidersLayoutComponent {
|
||||
false,
|
||||
);
|
||||
|
||||
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.EnableConsolidatedBilling,
|
||||
false,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
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 { 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 { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
import { ProviderPermissionsGuard } from "./guards/provider-permissions.guard";
|
||||
@ -64,6 +66,11 @@ const routes: Routes = [
|
||||
{ path: "", pathMatch: "full", redirectTo: "clients" },
|
||||
{ path: "clients/create", component: CreateOrganizationComponent },
|
||||
{ path: "clients", component: ClientsComponent, data: { titleId: "clients" } },
|
||||
{
|
||||
path: "manage-client-organizations",
|
||||
component: ManageClientOrganizationsComponent,
|
||||
data: { titleId: "manage-client-organizations" },
|
||||
},
|
||||
{
|
||||
path: "manage",
|
||||
children: [
|
||||
|
@ -8,6 +8,9 @@ import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
|
||||
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
|
||||
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 { ClientsComponent } from "./clients/clients.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
@ -50,6 +53,8 @@ import { SetupComponent } from "./setup/setup.component";
|
||||
SetupComponent,
|
||||
SetupProviderComponent,
|
||||
UserAddEditComponent,
|
||||
ManageClientOrganizationsComponent,
|
||||
ManageClientOrganizationSubscriptionComponent,
|
||||
],
|
||||
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 { 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 {
|
||||
cancelOrganizationSubscription: (
|
||||
@ -8,4 +10,10 @@ export abstract class BillingApiServiceAbstraction {
|
||||
) => Promise<void>;
|
||||
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
|
||||
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 { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
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 {
|
||||
constructor(private apiService: ApiService) {}
|
||||
@ -34,4 +36,29 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
|
||||
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",
|
||||
FlexibleCollectionsMigration = "flexible-collections-migration",
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user