1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[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
This commit is contained in:
Alex Morask 2024-06-24 11:15:53 -04:00 committed by GitHub
parent 043a7a39ff
commit fa1a6359bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 453 additions and 288 deletions

View File

@ -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."
}
}

View File

@ -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,

View File

@ -5,7 +5,7 @@
<bit-nav-item
icon="bwi-bank"
[text]="'clients' | i18n"
[route]="(canAccessBilling$ | async) ? 'manage-client-organizations' : 'clients'"
[route]="(hasConsolidatedBilling$ | async) ? 'manage-client-organizations' : 'clients'"
></bit-nav-item>
<bit-nav-group
icon="bwi-sliders"

View File

@ -1,13 +1,13 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { switchMap, Observable, Subject, filter, startWith } from "rxjs";
import { switchMap, Observable, Subject, combineLatest, map } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { canAccessBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
@ -37,6 +37,8 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected provider$: Observable<Provider>;
protected hasConsolidatedBilling$: Observable<boolean>;
protected canAccessBilling$: Observable<boolean>;
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$),
);
}

View File

@ -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",
},

View File

@ -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,

View File

@ -56,13 +56,15 @@
<input type="text" bitInput formControlName="seats" />
<bit-hint
class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2"
*ngIf="unassignedSeatsForSelectedPlan > 0"
*ngIf="openSeats > 0"
>
<span class="tw-col-span-1"
>{{ unassignedSeatsForSelectedPlan }}
{{ "unassignedSeatsDescription" | i18n | lowercase }}</span
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
>
<span class="tw-col-span-1"
>{{ additionalSeatsPurchased }}
{{ "purchaseSeatDescription" | i18n | lowercase }}</span
>
<span class="tw-col-span-1">0 {{ "purchaseSeatDescription" | i18n | lowercase }}</span>
</bit-hint>
</bit-form-field>
</div>

View File

@ -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<CreateClientOrganizationParams>,
dialogConfig: DialogConfig<CreateClientDialogParams>,
) =>
dialogService.open<CreateClientOrganizationResultType, CreateClientOrganizationParams>(
CreateClientOrganizationComponent,
dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>(
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<string>("", [Validators.required, Validators.email]),
organizationName: new FormControl<string>("", [Validators.required]),
seats: new FormControl<number>(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<CreateClientOrganizationResultType>,
private formBuilder: FormBuilder,
@Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams,
private dialogRef: DialogRef<CreateClientDialogResultType>,
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);
}
}

View File

@ -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";

View File

@ -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<ManageClientOrganizationNameParams>,
dialogConfig: DialogConfig<ManageClientNameDialogParams>,
) =>
dialogService.open<ManageClientOrganizationNameResultType, ManageClientOrganizationNameParams>(
ManageClientOrganizationNameComponent,
dialogService.open<ManageClientNameDialogResultType, ManageClientNameDialogParams>(
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<ManageClientOrganizationNameResultType>,
private dialogRef: DialogRef<ManageClientNameDialogResultType>,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private toastService: ToastService,

View File

@ -1,40 +0,0 @@
<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 disableMargin>
<bit-label>
{{ "assignedSeats" | i18n }}
</bit-label>
<input id="assignedSeats" type="number" bitInput required [(ngModel)]="assignedSeats" />
<bit-hint class="tw-text-muted" *ngIf="remainingOpenSeats > 0">
<div class="tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2">
<span class="tw-col-span-1"
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
>
<span class="tw-col-span-1">0 {{ "purchaseSeatDescription" | i18n | lowercase }}</span>
</div>
</bit-hint>
</bit-form-field>
</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>

View File

@ -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 });
}
}

View File

@ -0,0 +1,46 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "manageSeats" | i18n }}
<small class="tw-text-muted">{{ dialogParams.organization.organizationName }}</small>
</span>
<div bitDialogContent>
<p>{{ "manageSeatsDescription" | i18n }}</p>
<bit-form-field disableMargin>
<bit-label>
{{ "assignedSeats" | i18n }}
</bit-label>
<input type="number" bitInput formControlName="assignedSeats" />
<bit-hint class="tw-text-muted" *ngIf="openSeats > 0 || isServiceUserWithPurchasedSeats">
<div
*ngIf="!this.isServiceUserWithPurchasedSeats"
class="tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1"
[ngClass]="{ 'tw-grid-rows-2': this.isProviderAdmin }"
>
<span class="tw-col-span-1">
{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}
</span>
<span *ngIf="this.isProviderAdmin" class="tw-col-span-1"
>{{ additionalSeatsPurchased }}
{{ "purchaseSeatDescription" | i18n | lowercase }}</span
>
</div>
</bit-hint>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button
bitButton
bitFormButton
buttonType="primary"
type="submit"
[disabled]="formGroup.invalid"
>
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -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<ManageClientSubscriptionDialogParams>,
) =>
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<number>(this.dialogParams.organization.seats, [
Validators.required,
Validators.min(0),
]),
});
constructor(
private billingApiService: BillingApiServiceAbstraction,
@Inject(DIALOG_DATA) protected dialogParams: ManageClientSubscriptionDialogParams,
private dialogRef: DialogRef<ManageClientSubscriptionDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
) {}
async ngOnInit(): Promise<void> {
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<number>): 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<number>): 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;
}
}

View File

@ -1,12 +1,6 @@
<app-header>
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
<a
type="button"
bitButton
*ngIf="manageOrganizations"
buttonType="primary"
(click)="createClientOrganization()"
>
<a type="button" bitButton *ngIf="isProviderAdmin" buttonType="primary" (click)="createClient()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
@ -73,15 +67,15 @@
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="manageName(client)">
<button type="button" bitMenuItem (click)="manageClientName(client)">
<i aria-hidden="true" class="bwi bwi-pencil-square"></i>
{{ "updateName" | i18n }}
</button>
<button type="button" bitMenuItem (click)="manageSubscription(client)">
<button type="button" bitMenuItem (click)="manageClientSubscription(client)">
<i aria-hidden="true" class="bwi bwi-family"></i>
{{ "manageSubscription" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(client)">
<button *ngIf="this.isProviderAdmin" type="button" bitMenuItem (click)="remove(client)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
</span>
@ -92,6 +86,9 @@
</ng-template>
</bit-table>
<div *ngIf="clients.length === 0" class="tw-mt-10">
<app-no-clients (addNewOrganizationClicked)="createClientOrganization()" />
<app-no-clients
[showAddOrganizationButton]="this.isProviderAdmin"
(addNewOrganizationClicked)="createClient()"
/>
</div>
</ng-container>

View File

@ -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();
};
}

View File

@ -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: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
<bit-icon [icon]="icon"></bit-icon>
<p class="tw-mt-4">{{ "noClients" | i18n }}</p>
<a type="button" bitButton buttonType="primary" (click)="addNewOrganization()">
<a
*ngIf="showAddOrganizationButton"
type="button"
bitButton
buttonType="primary"
(click)="addNewOrganization()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
@ -34,6 +40,7 @@ const gearIcon = svgIcon`
})
export class NoClientsComponent {
icon = gearIcon;
@Input() showAddOrganizationButton = true;
@Output() addNewOrganizationClicked = new EventEmitter();
addNewOrganization = () => this.addNewOrganizationClicked.emit();

View File

@ -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]);

View File

@ -10,7 +10,7 @@
<ng-container header>
<tr>
<th bitCell>{{ "date" | i18n }}</th>
<th bitCell>{{ "invoiceNumberHeader" | i18n }}</th>
<th bitCell>{{ "invoice" | i18n }}</th>
<th bitCell>{{ "total" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
@ -29,7 +29,23 @@
</a>
</td>
<td bitCell>{{ invoice.total | currency: "$" }}</td>
<td bitCell>{{ invoice.status | titlecase }}</td>
<td bitCell *ngIf="expandInvoiceStatus(invoice) as expandedInvoiceStatus">
<span *ngIf="expandedInvoiceStatus === 'open'">
{{ "open" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'paid'">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'uncollectible'">
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "uncollectible" | i18n | titlecase }}
</span>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"

View File

@ -46,4 +46,19 @@ export class InvoicesComponent implements OnInit {
}
this.loading = false;
}
expandInvoiceStatus = (
invoice: InvoiceResponse,
): "open" | "unpaid" | "paid" | "uncollectible" => {
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;
}
}
};
}

View File

@ -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<MaybeProvider, boolean> =>
switchMap<MaybeProvider, Observable<boolean>>((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,
),
),

View File

@ -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");
}