diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bfad3f26281a..e9c1f229a51d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 95d1b03e7250..b8e5a5ff4d5b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" } } 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 dc3dea3c9ddb..20e98ce0842f 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 @@ -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; 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$); + + 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; - /* 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; + 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() { 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 333ea66e26cb..fe7f051652a3 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 @@ -4,7 +4,11 @@ - + + + {{ "manageSeats" | i18n }} + {{ clientName }} + +
+

+ {{ "manageSeatsDescription" | i18n }} +

+ + + {{ "assignedSeats" | i18n }} + + + + +

+ {{ unassignedSeats }} + {{ "unassignedSeatsDescription" | i18n }} +

+

+ {{ AdditionalSeatPurchased }} + {{ "purchaseSeatDescription" | 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 new file mode 100644 index 000000000000..2c8d59edc343 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts @@ -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 }); + } +} 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-client-organizations.component.html new file mode 100644 index 000000000000..dc303d338f9b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html @@ -0,0 +1,90 @@ + + + + + {{ "addNewOrganization" | i18n }} + + + + + + {{ "loading" | i18n }} + + + +

{{ "noClientsInList" | i18n }}

+ + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + + + {{ client.seats }} + + + {{ client.userCount }} + + + {{ client.seats - client.userCount }} + + + {{ client.plan }} + + + + + + + + + + + + +
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-client-organizations.component.ts new file mode 100644 index 000000000000..79dd25e8912e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts @@ -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; + private pagedClientsCount = 0; + selection = new SelectionModel(true, []); + protected dataSource = new TableDataSource(); + + 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; + } +} diff --git a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts index 3982fa917b68..1311976c4b15 100644 --- a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -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; cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise; getBillingStatus: (id: string) => Promise; + getProviderClientSubscriptions: (providerId: string) => Promise; + putProviderClientSubscriptions: ( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/provider-subscription-update.request.ts b/libs/common/src/billing/models/request/provider-subscription-update.request.ts new file mode 100644 index 000000000000..f2bf4c7e971f --- /dev/null +++ b/libs/common/src/billing/models/request/provider-subscription-update.request.ts @@ -0,0 +1,3 @@ +export class ProviderSubscriptionUpdateRequest { + assignedSeats: number; +} diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts new file mode 100644 index 000000000000..522c5187254d --- /dev/null +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -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"); + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 3d0ff550ea62..48866ab90d19 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -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 { + 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 { + return await this.apiService.send( + "PUT", + "/providers/" + providerId + "/organizations/" + organizationId, + request, + true, + false, + ); + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ca5ccc17b532..9470db94474d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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