From fa1a6359bcb0b385ac1c9e994c7d88f71bf9b64b Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:15:53 -0400 Subject: [PATCH] [AC-2774] [AC-2781] Consolidated issues for Consolidated Billing (#9717) * Rename provider client components for brevity * Make purchased seats dynamic on create client component * Fix access and empty state for service users * Refactor manage client subscription dialog * Fixed manage subscription dialog errors * Make unassigned seats dynamic for create client dialog * Expanded invoice statuses * Update invoice header on invoices component --- apps/web/src/locales/en/messages.json | 30 ++- .../providers/clients/clients.component.ts | 8 +- .../providers/providers-layout.component.html | 2 +- .../providers/providers-layout.component.ts | 19 +- .../providers/providers-routing.module.ts | 9 +- .../providers/providers.module.ts | 16 +- ...ml => create-client-dialog.component.html} | 10 +- ...t.ts => create-client-dialog.component.ts} | 73 ++++--- .../app/billing/providers/clients/index.ts | 8 +- ... manage-client-name-dialog.component.html} | 0 ...=> manage-client-name-dialog.component.ts} | 23 ++- ...t-organization-subscription.component.html | 40 ---- ...ent-organization-subscription.component.ts | 116 ----------- ...-client-subscription-dialog.component.html | 46 +++++ ...ge-client-subscription-dialog.component.ts | 180 ++++++++++++++++++ ...ent.html => manage-clients.component.html} | 19 +- ...mponent.ts => manage-clients.component.ts} | 87 +++++---- .../providers/clients/no-clients.component.ts | 11 +- .../guards/has-consolidated-billing.guard.ts | 1 - .../invoices/invoices.component.html | 20 +- .../components/invoices/invoices.component.ts | 15 ++ .../provider-billing.service.abstraction.ts | 6 +- .../models/response/invoices.response.ts | 2 + 23 files changed, 453 insertions(+), 288 deletions(-) rename bitwarden_license/bit-web/src/app/billing/providers/clients/{create-client-organization.component.html => create-client-dialog.component.html} (90%) rename bitwarden_license/bit-web/src/app/billing/providers/clients/{create-client-organization.component.ts => create-client-dialog.component.ts} (70%) rename bitwarden_license/bit-web/src/app/billing/providers/clients/{manage-client-organization-name.component.html => manage-client-name-dialog.component.html} (100%) rename bitwarden_license/bit-web/src/app/billing/providers/clients/{manage-client-organization-name.component.ts => manage-client-name-dialog.component.ts} (70%) delete mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html delete mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts rename bitwarden_license/bit-web/src/app/billing/providers/clients/{manage-client-organizations.component.html => manage-clients.component.html} (85%) rename bitwarden_license/bit-web/src/app/billing/providers/clients/{manage-client-organizations.component.ts => manage-clients.component.ts} (69%) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ce4d28223f..d649797750 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7892,7 +7892,7 @@ "message": "Adjustments to seats will be reflected in the next billing cycle." }, "unassignedSeatsDescription": { - "message": "Unassigned subscription seats" + "message": "Unassigned seats" }, "purchaseSeatDescription": { "message": "Additional seats purchased" @@ -8399,10 +8399,6 @@ "exportClientReport": { "message": "Export client report" }, - "invoiceNumberHeader": { - "message": "Invoice number", - "description": "A table header for an invoice's number" - }, "memberAccessReport": { "message": "Member access" }, @@ -8450,5 +8446,29 @@ }, "smAccessRemovalSecretMessage": { "message": "This action will remove your access to this secret." + }, + "invoice": { + "message": "Invoice" + }, + "unassignedSeatsAvailable": { + "message": "You have $SEATS$ unassigned seats available.", + "placeholders": { + "seats": { + "content": "$1", + "example": "10" + } + }, + "description": "A message showing how many unassigned seats are available for a provider." + }, + "contactYourProviderForAdditionalSeats": { + "message": "Contact your provider admin to purchase additional seats." + }, + "open": { + "message": "Open", + "description": "The status of an invoice." + }, + "uncollectible": { + "message": "Uncollectible", + "description": "The status of an invoice." } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts index 7a96bdc7c7..2e422b8136 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts @@ -10,7 +10,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { canAccessBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction"; +import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction"; import { PlanType } from "@bitwarden/common/billing/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -72,9 +72,9 @@ export class ClientsComponent extends BaseClientsComponent { switchMap((params) => { this.providerId = params.providerId; return this.providerService.get$(this.providerId).pipe( - canAccessBilling(this.configService), - map((canAccessBilling) => { - if (canAccessBilling) { + hasConsolidatedBilling(this.configService), + map((hasConsolidatedBilling) => { + if (hasConsolidatedBilling) { return from( this.router.navigate(["../manage-client-organizations"], { relativeTo: this.activatedRoute, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 7d1d195bf9..3bd5053994 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -5,7 +5,7 @@ (); protected provider$: Observable; + + protected hasConsolidatedBilling$: Observable; protected canAccessBilling$: Observable; protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$( @@ -57,10 +59,15 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ); - this.canAccessBilling$ = this.provider$.pipe( - filter((provider) => !!provider), - canAccessBilling(this.configService), - startWith(false), + this.hasConsolidatedBilling$ = this.provider$.pipe( + hasConsolidatedBilling(this.configService), + takeUntil(this.destroy$), + ); + + this.canAccessBilling$ = combineLatest([this.hasConsolidatedBilling$, this.provider$]).pipe( + map( + ([hasConsolidatedBilling, provider]) => hasConsolidatedBilling && provider.isProviderAdmin, + ), takeUntil(this.destroy$), ); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index c7b33b5514..f69dd0d2a8 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -9,7 +9,7 @@ import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/fronte import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component"; import { - ManageClientOrganizationsComponent, + ManageClientsComponent, ProviderSubscriptionComponent, hasConsolidatedBilling, ProviderPaymentMethodComponent, @@ -85,7 +85,7 @@ const routes: Routes = [ { path: "manage-client-organizations", canActivate: [hasConsolidatedBilling], - component: ManageClientOrganizationsComponent, + component: ManageClientsComponent, data: { titleId: "clients" }, }, { @@ -118,7 +118,7 @@ const routes: Routes = [ }, { path: "billing", - canActivate: [hasConsolidatedBilling], + canActivate: [ProviderPermissionsGuard, hasConsolidatedBilling], data: { providerPermissions: (provider: Provider) => provider.isProviderAdmin }, children: [ { @@ -129,6 +129,7 @@ const routes: Routes = [ { path: "subscription", component: ProviderSubscriptionComponent, + canActivate: [ProviderPermissionsGuard], data: { titleId: "subscription", }, @@ -136,6 +137,7 @@ const routes: Routes = [ { path: "payment-method", component: ProviderPaymentMethodComponent, + canActivate: [ProviderPermissionsGuard], data: { titleId: "paymentMethod", }, @@ -143,6 +145,7 @@ const routes: Routes = [ { path: "history", component: ProviderBillingHistoryComponent, + canActivate: [ProviderPermissionsGuard], data: { titleId: "billingHistory", }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 728f64fb12..cd1a225a8e 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -10,11 +10,11 @@ import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/sh import { OssModule } from "@bitwarden/web-vault/app/oss.module"; import { - CreateClientOrganizationComponent, + CreateClientDialogComponent, NoClientsComponent, - ManageClientOrganizationNameComponent, - ManageClientOrganizationsComponent, - ManageClientOrganizationSubscriptionComponent, + ManageClientNameDialogComponent, + ManageClientsComponent, + ManageClientSubscriptionDialogComponent, ProviderBillingHistoryComponent, ProviderPaymentMethodComponent, ProviderSelectPaymentMethodDialogComponent, @@ -66,11 +66,11 @@ import { SetupComponent } from "./setup/setup.component"; SetupComponent, SetupProviderComponent, UserAddEditComponent, - CreateClientOrganizationComponent, + CreateClientDialogComponent, NoClientsComponent, - ManageClientOrganizationsComponent, - ManageClientOrganizationNameComponent, - ManageClientOrganizationSubscriptionComponent, + ManageClientsComponent, + ManageClientNameDialogComponent, + ManageClientSubscriptionDialogComponent, ProviderBillingHistoryComponent, ProviderSubscriptionComponent, ProviderSelectPaymentMethodDialogComponent, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html similarity index 90% rename from bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html rename to bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html index 110990d709..7206725301 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html @@ -56,13 +56,15 @@ 0" + *ngIf="openSeats > 0" > {{ unassignedSeatsForSelectedPlan }} - {{ "unassignedSeatsDescription" | i18n | lowercase }}{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }} + {{ additionalSeatsPurchased }} + {{ "purchaseSeatDescription" | i18n | lowercase }} - 0 {{ "purchaseSeatDescription" | i18n | lowercase }} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts similarity index 70% rename from bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts index 13d74136cf..329aba522f 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject, OnInit } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { PlanType } from "@bitwarden/common/billing/enums"; @@ -11,22 +11,22 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; -type CreateClientOrganizationParams = { +type CreateClientDialogParams = { providerId: string; plans: PlanResponse[]; }; -export enum CreateClientOrganizationResultType { +export enum CreateClientDialogResultType { Closed = "closed", Submitted = "submitted", } -export const openCreateClientOrganizationDialog = ( +export const openCreateClientDialog = ( dialogService: DialogService, - dialogConfig: DialogConfig, + dialogConfig: DialogConfig, ) => - dialogService.open( - CreateClientOrganizationComponent, + dialogService.open( + CreateClientDialogComponent, dialogConfig, ); @@ -39,26 +39,24 @@ type PlanCard = { }; @Component({ - selector: "app-create-client-organization", - templateUrl: "./create-client-organization.component.html", + templateUrl: "./create-client-dialog.component.html", }) -export class CreateClientOrganizationComponent implements OnInit { - protected formGroup = this.formBuilder.group({ - clientOwnerEmail: ["", [Validators.required, Validators.email]], - organizationName: ["", Validators.required], - seats: [null, [Validators.required, Validators.min(1)]], +export class CreateClientDialogComponent implements OnInit { + protected formGroup = new FormGroup({ + clientOwnerEmail: new FormControl("", [Validators.required, Validators.email]), + organizationName: new FormControl("", [Validators.required]), + seats: new FormControl(null, [Validators.required, Validators.min(1)]), }); protected loading = true; protected planCards: PlanCard[]; - protected ResultType = CreateClientOrganizationResultType; + protected ResultType = CreateClientDialogResultType; private providerPlans: ProviderPlanResponse[]; constructor( private billingApiService: BillingApiServiceAbstraction, - @Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams, - private dialogRef: DialogRef, - private formBuilder: FormBuilder, + @Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams, + private dialogRef: DialogRef, private i18nService: I18nService, private toastService: ToastService, private webProviderService: WebProviderService, @@ -159,14 +157,41 @@ export class CreateClientOrganizationComponent implements OnInit { this.dialogRef.close(this.ResultType.Submitted); }; - protected get unassignedSeatsForSelectedPlan(): number { - if (this.loading || !this.planCards) { + protected get openSeats(): number { + const selectedProviderPlan = this.getSelectedProviderPlan(); + + if (selectedProviderPlan === null) { return 0; } - const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan; - const selectedProviderPlan = this.providerPlans.find( - (providerPlan) => providerPlan.planName === selectedPlan.name, - ); + return selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats; } + + protected get unassignedSeats(): number { + const unassignedSeats = this.openSeats - this.formGroup.value.seats; + + return unassignedSeats > 0 ? unassignedSeats : 0; + } + + protected get additionalSeatsPurchased(): number { + const selectedProviderPlan = this.getSelectedProviderPlan(); + + if (selectedProviderPlan === null) { + return 0; + } + + const selectedSeats = this.formGroup.value.seats ?? 0; + + const purchased = selectedSeats - this.openSeats; + + return purchased > 0 ? purchased : 0; + } + + private getSelectedProviderPlan(): ProviderPlanResponse { + if (this.loading || !this.planCards) { + return null; + } + const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan; + return this.providerPlans.find((providerPlan) => providerPlan.planName === selectedPlan.name); + } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts index fa1bc137fc..ae7bf487f9 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts @@ -1,5 +1,5 @@ -export * from "./create-client-organization.component"; -export * from "./manage-client-organizations.component"; -export * from "./manage-client-organization-name.component"; -export * from "./manage-client-organization-subscription.component"; +export * from "./create-client-dialog.component"; +export * from "./manage-clients.component"; +export * from "./manage-client-name-dialog.component"; +export * from "./manage-client-subscription-dialog.component"; export * from "./no-clients.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-name.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-name-dialog.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-name.component.html rename to bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-name-dialog.component.html diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-name.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-name-dialog.component.ts similarity index 70% rename from bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-name.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-name-dialog.component.ts index 81e01a66cb..be46308c1c 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-name.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-name-dialog.component.ts @@ -7,7 +7,7 @@ import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/model import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; -type ManageClientOrganizationNameParams = { +type ManageClientNameDialogParams = { providerId: string; organization: { id: string; @@ -16,34 +16,33 @@ type ManageClientOrganizationNameParams = { }; }; -export enum ManageClientOrganizationNameResultType { +export enum ManageClientNameDialogResultType { Closed = "closed", Submitted = "submitted", } -export const openManageClientOrganizationNameDialog = ( +export const openManageClientNameDialog = ( dialogService: DialogService, - dialogConfig: DialogConfig, + dialogConfig: DialogConfig, ) => - dialogService.open( - ManageClientOrganizationNameComponent, + dialogService.open( + ManageClientNameDialogComponent, dialogConfig, ); @Component({ - selector: "app-manage-client-organization-name", - templateUrl: "manage-client-organization-name.component.html", + templateUrl: "manage-client-name-dialog.component.html", }) -export class ManageClientOrganizationNameComponent { - protected ResultType = ManageClientOrganizationNameResultType; +export class ManageClientNameDialogComponent { + protected ResultType = ManageClientNameDialogResultType; protected formGroup = this.formBuilder.group({ name: [this.dialogParams.organization.name, Validators.required], }); constructor( - @Inject(DIALOG_DATA) protected dialogParams: ManageClientOrganizationNameParams, + @Inject(DIALOG_DATA) protected dialogParams: ManageClientNameDialogParams, private billingApiService: BillingApiServiceAbstraction, - private dialogRef: DialogRef, + private dialogRef: DialogRef, private formBuilder: FormBuilder, private i18nService: I18nService, private toastService: ToastService, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html deleted file mode 100644 index 8181c285c2..0000000000 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html +++ /dev/null @@ -1,40 +0,0 @@ - - - {{ "manageSeats" | i18n }} - {{ clientName }} - - - - {{ "manageSeatsDescription" | i18n }} - - - - {{ "assignedSeats" | i18n }} - - - 0"> - - {{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }} - 0 {{ "purchaseSeatDescription" | i18n | lowercase }} - - - - - - - - {{ "save" | i18n }} - - - {{ "cancel" | i18n }} - - - diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts deleted file mode 100644 index 496a8b18cb..0000000000 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts +++ /dev/null @@ -1,116 +0,0 @@ -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 { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request"; -import { ProviderPlanResponse } 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.getProviderSubscription(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 UpdateClientOrganizationRequest(); - request.assignedSeats = assignedSeats; - request.name = this.clientName; - - await this.billingApiService.updateClientOrganization( - this.providerId, - this.providerOrganizationId, - request, - ); - this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); - this.loading = false; - this.dialogRef.close(); - } - - getPurchasedSeatsByPlan(planName: string, plans: ProviderPlanResponse[]): number { - const plan = plans.find((plan) => plan.planName === planName); - if (plan) { - return plan.purchasedSeats; - } else { - return 0; - } - } - - getAssignedByPlan(planName: string, plans: ProviderPlanResponse[]): number { - const plan = plans.find((plan) => plan.planName === planName); - if (plan) { - return plan.assignedSeats; - } else { - return 0; - } - } - - getProviderSeatMinimumByPlan(planName: string, plans: ProviderPlanResponse[]) { - 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 }); - } -} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html new file mode 100644 index 0000000000..6f835eb5eb --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html @@ -0,0 +1,46 @@ + + + + {{ "manageSeats" | i18n }} + {{ dialogParams.organization.organizationName }} + + + {{ "manageSeatsDescription" | i18n }} + + + {{ "assignedSeats" | i18n }} + + + 0 || isServiceUserWithPurchasedSeats"> + + + {{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }} + + {{ additionalSeatsPurchased }} + {{ "purchaseSeatDescription" | i18n | lowercase }} + + + + + + + {{ "save" | i18n }} + + + {{ "cancel" | i18n }} + + + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts new file mode 100644 index 0000000000..45ace513ae --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts @@ -0,0 +1,180 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from "@angular/forms"; + +import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request"; +import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +type ManageClientSubscriptionDialogParams = { + organization: ProviderOrganizationOrganizationDetailsResponse; + provider: Provider; +}; + +export enum ManageClientSubscriptionDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +export const openManageClientSubscriptionDialog = ( + dialogService: DialogService, + dialogConfig: DialogConfig, +) => + dialogService.open< + ManageClientSubscriptionDialogResultType, + ManageClientSubscriptionDialogParams + >(ManageClientSubscriptionDialogComponent, dialogConfig); + +@Component({ + templateUrl: "./manage-client-subscription-dialog.component.html", +}) +export class ManageClientSubscriptionDialogComponent implements OnInit { + protected loading = true; + protected providerPlan: ProviderPlanResponse; + protected openSeats: number; + protected readonly ResultType = ManageClientSubscriptionDialogResultType; + + protected formGroup = new FormGroup({ + assignedSeats: new FormControl(this.dialogParams.organization.seats, [ + Validators.required, + Validators.min(0), + ]), + }); + + constructor( + private billingApiService: BillingApiServiceAbstraction, + @Inject(DIALOG_DATA) protected dialogParams: ManageClientSubscriptionDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + async ngOnInit(): Promise { + const response = await this.billingApiService.getProviderSubscription( + this.dialogParams.provider.id, + ); + + this.providerPlan = response.plans.find( + (plan) => plan.planName === this.dialogParams.organization.plan, + ); + + this.openSeats = this.providerPlan.seatMinimum - this.providerPlan.assignedSeats; + + this.formGroup.controls.assignedSeats.addValidators( + this.isServiceUserWithPurchasedSeats + ? this.createPurchasedSeatsValidator() + : this.createUnassignedSeatsValidator(), + ); + + this.loading = false; + } + + submit = async () => { + this.loading = true; + + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const request = new UpdateClientOrganizationRequest(); + request.assignedSeats = this.formGroup.value.assignedSeats; + request.name = this.dialogParams.organization.organizationName; + + await this.billingApiService.updateClientOrganization( + this.dialogParams.provider.id, + this.dialogParams.organization.id, + request, + ); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("subscriptionUpdated"), + }); + + this.loading = false; + this.dialogRef.close(this.ResultType.Submitted); + }; + + createPurchasedSeatsValidator = + (): ValidatorFn => + (formControl: FormControl): ValidationErrors | null => { + if (this.isProviderAdmin) { + return null; + } + + const seatAdjustment = formControl.value - this.dialogParams.organization.seats; + + if (seatAdjustment <= 0) { + return null; + } + + return { + insufficientPermissions: { + message: this.i18nService.t("contactYourProviderForAdditionalSeats"), + }, + }; + }; + + createUnassignedSeatsValidator = + (): ValidatorFn => + (formControl: FormControl): ValidationErrors | null => { + if (this.isProviderAdmin) { + return null; + } + + const seatAdjustment = formControl.value - this.dialogParams.organization.seats; + + if (seatAdjustment <= this.openSeats) { + return null; + } + + const unassignedSeatsAvailableMessage = this.i18nService.t( + "unassignedSeatsAvailable", + this.openSeats, + ); + + const contactYourProviderMessage = this.i18nService.t( + "contactYourProviderForAdditionalSeats", + ); + + return { + insufficientPermissions: { + message: `${unassignedSeatsAvailableMessage} ${contactYourProviderMessage}`, + }, + }; + }; + + get unassignedSeats(): number { + const seatDifference = + this.formGroup.value.assignedSeats - this.dialogParams.organization.seats; + + const unassignedSeats = this.openSeats - seatDifference; + + return unassignedSeats >= 0 ? unassignedSeats : 0; + } + + get additionalSeatsPurchased(): number { + const seatDifference = + this.formGroup.value.assignedSeats - this.dialogParams.organization.seats; + + const purchasedSeats = seatDifference - this.openSeats; + + return purchasedSeats > 0 ? purchasedSeats : 0; + } + + get isProviderAdmin(): boolean { + return this.dialogParams.provider.type === ProviderUserType.ProviderAdmin; + } + + get isServiceUserWithPurchasedSeats(): boolean { + return !this.isProviderAdmin && this.providerPlan && this.providerPlan.purchasedSeats > 0; + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html similarity index 85% rename from bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html rename to bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html index 9a84b92837..2468f9df1a 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html @@ -1,12 +1,6 @@ - + {{ "addNewOrganization" | i18n }} @@ -73,15 +67,15 @@ appA11yTitle="{{ 'options' | i18n }}" > - + {{ "updateName" | i18n }} - + {{ "manageSubscription" | i18n }} - + {{ "unlinkOrganization" | i18n }} @@ -92,6 +86,9 @@ - + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts similarity index 69% rename from bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts index 77621fec33..09c841aecd 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts @@ -10,7 +10,7 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; 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 { canAccessBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction"; +import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -21,24 +21,27 @@ import { BaseClientsComponent } from "../../../admin-console/providers/clients/b import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; import { - CreateClientOrganizationResultType, - openCreateClientOrganizationDialog, -} from "./create-client-organization.component"; + CreateClientDialogResultType, + openCreateClientDialog, +} from "./create-client-dialog.component"; import { - ManageClientOrganizationNameResultType, - openManageClientOrganizationNameDialog, -} from "./manage-client-organization-name.component"; -import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component"; + ManageClientNameDialogResultType, + openManageClientNameDialog, +} from "./manage-client-name-dialog.component"; +import { + ManageClientSubscriptionDialogResultType, + openManageClientSubscriptionDialog, +} from "./manage-client-subscription-dialog.component"; @Component({ - templateUrl: "manage-client-organizations.component.html", + templateUrl: "manage-clients.component.html", }) -export class ManageClientOrganizationsComponent extends BaseClientsComponent { +export class ManageClientsComponent extends BaseClientsComponent { providerId: string; provider: Provider; loading = true; - manageOrganizations = false; + isProviderAdmin = false; protected plans: PlanResponse[]; @@ -73,9 +76,9 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent { switchMap((params) => { this.providerId = params.providerId; return this.providerService.get$(this.providerId).pipe( - canAccessBilling(this.configService), - map((canAccessBilling) => { - if (!canAccessBilling) { + hasConsolidatedBilling(this.configService), + map((hasConsolidatedBilling) => { + if (!hasConsolidatedBilling) { return from( this.router.navigate(["../clients"], { relativeTo: this.activatedRoute, @@ -99,7 +102,7 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent { async load() { this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); - this.manageOrganizations = this.provider.type === ProviderUserType.ProviderAdmin; + this.isProviderAdmin = this.provider.type === ProviderUserType.ProviderAdmin; this.clients = (await this.apiService.getProviderClients(this.providerId)).data; @@ -110,8 +113,23 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent { this.loading = false; } - async manageName(organization: ProviderOrganizationOrganizationDetailsResponse) { - const dialogRef = openManageClientOrganizationNameDialog(this.dialogService, { + createClient = async () => { + const reference = openCreateClientDialog(this.dialogService, { + data: { + providerId: this.providerId, + plans: this.plans, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === CreateClientDialogResultType.Submitted) { + await this.load(); + } + }; + + manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => { + const dialogRef = openManageClientNameDialog(this.dialogService, { data: { providerId: this.providerId, organization: { @@ -124,38 +142,25 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent { const result = await firstValueFrom(dialogRef.closed); - if (result === ManageClientOrganizationNameResultType.Submitted) { + if (result === ManageClientNameDialogResultType.Submitted) { await this.load(); } - } + }; - async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) { - if (organization == null) { - return; - } - - const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, { - organization: organization, - }); - - await firstValueFrom(dialogRef.closed); - await this.load(); - } - - createClientOrganization = async () => { - const reference = openCreateClientOrganizationDialog(this.dialogService, { + manageClientSubscription = async ( + organization: ProviderOrganizationOrganizationDetailsResponse, + ) => { + const dialogRef = openManageClientSubscriptionDialog(this.dialogService, { data: { - providerId: this.providerId, - plans: this.plans, + organization, + provider: this.provider, }, }); - const result = await lastValueFrom(reference.closed); + const result = await firstValueFrom(dialogRef.closed); - if (result === CreateClientOrganizationResultType.Closed) { - return; + if (result === ManageClientSubscriptionDialogResultType.Submitted) { + await this.load(); } - - await this.load(); }; } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts index c785ee8bd0..30ab444fe8 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Output } from "@angular/core"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; import { svgIcon } from "@bitwarden/components"; @@ -26,7 +26,13 @@ const gearIcon = svgIcon` template: ` {{ "noClients" | i18n }} - + {{ "addNewOrganization" | i18n }} @@ -34,6 +40,7 @@ const gearIcon = svgIcon` }) export class NoClientsComponent { icon = gearIcon; + @Input() showAddOrganizationButton = true; @Output() addNewOrganizationClicked = new EventEmitter(); addNewOrganization = () => this.addNewOrganizationClicked.emit(); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/guards/has-consolidated-billing.guard.ts b/bitwarden_license/bit-web/src/app/billing/providers/guards/has-consolidated-billing.guard.ts index 7d6f5bb5f0..213b9a5368 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/guards/has-consolidated-billing.guard.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/guards/has-consolidated-billing.guard.ts @@ -20,7 +20,6 @@ export const hasConsolidatedBilling: CanActivateFn = async (route: ActivatedRout if ( !consolidatedBillingEnabled || !provider || - !provider.isProviderAdmin || provider.providerStatus !== ProviderStatusType.Billable ) { return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]); diff --git a/libs/angular/src/billing/components/invoices/invoices.component.html b/libs/angular/src/billing/components/invoices/invoices.component.html index c382300554..2ce01aa50b 100644 --- a/libs/angular/src/billing/components/invoices/invoices.component.html +++ b/libs/angular/src/billing/components/invoices/invoices.component.html @@ -10,7 +10,7 @@ {{ "date" | i18n }} - {{ "invoiceNumberHeader" | i18n }} + {{ "invoice" | i18n }} {{ "total" | i18n }} {{ "status" | i18n }} @@ -29,7 +29,23 @@ {{ invoice.total | currency: "$" }} - {{ invoice.status | titlecase }} + + + {{ "open" | i18n | titlecase }} + + + + {{ "unpaid" | i18n | titlecase }} + + + + {{ "paid" | i18n | titlecase }} + + + + {{ "uncollectible" | i18n | titlecase }} + + { + switch (invoice.status) { + case "open": { + const dueDate = new Date(invoice.dueDate); + return dueDate < new Date() ? "unpaid" : invoice.status; + } + case "paid": + case "uncollectible": { + return invoice.status; + } + } + }; } diff --git a/libs/common/src/billing/abstractions/provider-billing.service.abstraction.ts b/libs/common/src/billing/abstractions/provider-billing.service.abstraction.ts index 0f65dee408..aa8568d8e9 100644 --- a/libs/common/src/billing/abstractions/provider-billing.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/provider-billing.service.abstraction.ts @@ -7,7 +7,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co type MaybeProvider = Provider | undefined; -export const canAccessBilling = ( +export const hasConsolidatedBilling = ( configService: ConfigService, ): OperatorFunction => switchMap>((provider) => @@ -16,9 +16,7 @@ export const canAccessBilling = ( .pipe( map((consolidatedBillingEnabled) => provider - ? provider.isProviderAdmin && - provider.providerStatus === ProviderStatusType.Billable && - consolidatedBillingEnabled + ? provider.providerStatus === ProviderStatusType.Billable && consolidatedBillingEnabled : false, ), ), diff --git a/libs/common/src/billing/models/response/invoices.response.ts b/libs/common/src/billing/models/response/invoices.response.ts index 73170ff5c9..2725d083be 100644 --- a/libs/common/src/billing/models/response/invoices.response.ts +++ b/libs/common/src/billing/models/response/invoices.response.ts @@ -18,6 +18,7 @@ export class InvoiceResponse extends BaseResponse { number: string; total: number; status: string; + dueDate: string; url: string; pdfUrl: string; @@ -28,6 +29,7 @@ export class InvoiceResponse extends BaseResponse { this.number = this.getResponseProperty("Number"); this.total = this.getResponseProperty("Total"); this.status = this.getResponseProperty("Status"); + this.dueDate = this.getResponseProperty("DueDate"); this.url = this.getResponseProperty("Url"); this.pdfUrl = this.getResponseProperty("PdfUrl"); }
- {{ "manageSeatsDescription" | i18n }} -
{{ "manageSeatsDescription" | i18n }}
{{ "noClients" | i18n }}