mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-30 17:47:44 +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:
parent
043a7a39ff
commit
fa1a6359bc
@ -7892,7 +7892,7 @@
|
|||||||
"message": "Adjustments to seats will be reflected in the next billing cycle."
|
"message": "Adjustments to seats will be reflected in the next billing cycle."
|
||||||
},
|
},
|
||||||
"unassignedSeatsDescription": {
|
"unassignedSeatsDescription": {
|
||||||
"message": "Unassigned subscription seats"
|
"message": "Unassigned seats"
|
||||||
},
|
},
|
||||||
"purchaseSeatDescription": {
|
"purchaseSeatDescription": {
|
||||||
"message": "Additional seats purchased"
|
"message": "Additional seats purchased"
|
||||||
@ -8399,10 +8399,6 @@
|
|||||||
"exportClientReport": {
|
"exportClientReport": {
|
||||||
"message": "Export client report"
|
"message": "Export client report"
|
||||||
},
|
},
|
||||||
"invoiceNumberHeader": {
|
|
||||||
"message": "Invoice number",
|
|
||||||
"description": "A table header for an invoice's number"
|
|
||||||
},
|
|
||||||
"memberAccessReport": {
|
"memberAccessReport": {
|
||||||
"message": "Member access"
|
"message": "Member access"
|
||||||
},
|
},
|
||||||
@ -8450,5 +8446,29 @@
|
|||||||
},
|
},
|
||||||
"smAccessRemovalSecretMessage": {
|
"smAccessRemovalSecretMessage": {
|
||||||
"message": "This action will remove your access to this secret."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
|||||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { 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 { PlanType } from "@bitwarden/common/billing/enums";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@ -72,9 +72,9 @@ export class ClientsComponent extends BaseClientsComponent {
|
|||||||
switchMap((params) => {
|
switchMap((params) => {
|
||||||
this.providerId = params.providerId;
|
this.providerId = params.providerId;
|
||||||
return this.providerService.get$(this.providerId).pipe(
|
return this.providerService.get$(this.providerId).pipe(
|
||||||
canAccessBilling(this.configService),
|
hasConsolidatedBilling(this.configService),
|
||||||
map((canAccessBilling) => {
|
map((hasConsolidatedBilling) => {
|
||||||
if (canAccessBilling) {
|
if (hasConsolidatedBilling) {
|
||||||
return from(
|
return from(
|
||||||
this.router.navigate(["../manage-client-organizations"], {
|
this.router.navigate(["../manage-client-organizations"], {
|
||||||
relativeTo: this.activatedRoute,
|
relativeTo: this.activatedRoute,
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<bit-nav-item
|
<bit-nav-item
|
||||||
icon="bwi-bank"
|
icon="bwi-bank"
|
||||||
[text]="'clients' | i18n"
|
[text]="'clients' | i18n"
|
||||||
[route]="(canAccessBilling$ | async) ? 'manage-client-organizations' : 'clients'"
|
[route]="(hasConsolidatedBilling$ | async) ? 'manage-client-organizations' : 'clients'"
|
||||||
></bit-nav-item>
|
></bit-nav-item>
|
||||||
<bit-nav-group
|
<bit-nav-group
|
||||||
icon="bwi-sliders"
|
icon="bwi-sliders"
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
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 { takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
|
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||||
@ -37,6 +37,8 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
protected provider$: Observable<Provider>;
|
protected provider$: Observable<Provider>;
|
||||||
|
|
||||||
|
protected hasConsolidatedBilling$: Observable<boolean>;
|
||||||
protected canAccessBilling$: Observable<boolean>;
|
protected canAccessBilling$: Observable<boolean>;
|
||||||
|
|
||||||
protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$(
|
protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$(
|
||||||
@ -57,10 +59,15 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
|||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.canAccessBilling$ = this.provider$.pipe(
|
this.hasConsolidatedBilling$ = this.provider$.pipe(
|
||||||
filter((provider) => !!provider),
|
hasConsolidatedBilling(this.configService),
|
||||||
canAccessBilling(this.configService),
|
takeUntil(this.destroy$),
|
||||||
startWith(false),
|
);
|
||||||
|
|
||||||
|
this.canAccessBilling$ = combineLatest([this.hasConsolidatedBilling$, this.provider$]).pipe(
|
||||||
|
map(
|
||||||
|
([hasConsolidatedBilling, provider]) => hasConsolidatedBilling && provider.isProviderAdmin,
|
||||||
|
),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ManageClientOrganizationsComponent,
|
ManageClientsComponent,
|
||||||
ProviderSubscriptionComponent,
|
ProviderSubscriptionComponent,
|
||||||
hasConsolidatedBilling,
|
hasConsolidatedBilling,
|
||||||
ProviderPaymentMethodComponent,
|
ProviderPaymentMethodComponent,
|
||||||
@ -85,7 +85,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "manage-client-organizations",
|
path: "manage-client-organizations",
|
||||||
canActivate: [hasConsolidatedBilling],
|
canActivate: [hasConsolidatedBilling],
|
||||||
component: ManageClientOrganizationsComponent,
|
component: ManageClientsComponent,
|
||||||
data: { titleId: "clients" },
|
data: { titleId: "clients" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -118,7 +118,7 @@ const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "billing",
|
path: "billing",
|
||||||
canActivate: [hasConsolidatedBilling],
|
canActivate: [ProviderPermissionsGuard, hasConsolidatedBilling],
|
||||||
data: { providerPermissions: (provider: Provider) => provider.isProviderAdmin },
|
data: { providerPermissions: (provider: Provider) => provider.isProviderAdmin },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@ -129,6 +129,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "subscription",
|
path: "subscription",
|
||||||
component: ProviderSubscriptionComponent,
|
component: ProviderSubscriptionComponent,
|
||||||
|
canActivate: [ProviderPermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
titleId: "subscription",
|
titleId: "subscription",
|
||||||
},
|
},
|
||||||
@ -136,6 +137,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "payment-method",
|
path: "payment-method",
|
||||||
component: ProviderPaymentMethodComponent,
|
component: ProviderPaymentMethodComponent,
|
||||||
|
canActivate: [ProviderPermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
titleId: "paymentMethod",
|
titleId: "paymentMethod",
|
||||||
},
|
},
|
||||||
@ -143,6 +145,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "history",
|
path: "history",
|
||||||
component: ProviderBillingHistoryComponent,
|
component: ProviderBillingHistoryComponent,
|
||||||
|
canActivate: [ProviderPermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
titleId: "billingHistory",
|
titleId: "billingHistory",
|
||||||
},
|
},
|
||||||
|
@ -10,11 +10,11 @@ import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/sh
|
|||||||
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateClientOrganizationComponent,
|
CreateClientDialogComponent,
|
||||||
NoClientsComponent,
|
NoClientsComponent,
|
||||||
ManageClientOrganizationNameComponent,
|
ManageClientNameDialogComponent,
|
||||||
ManageClientOrganizationsComponent,
|
ManageClientsComponent,
|
||||||
ManageClientOrganizationSubscriptionComponent,
|
ManageClientSubscriptionDialogComponent,
|
||||||
ProviderBillingHistoryComponent,
|
ProviderBillingHistoryComponent,
|
||||||
ProviderPaymentMethodComponent,
|
ProviderPaymentMethodComponent,
|
||||||
ProviderSelectPaymentMethodDialogComponent,
|
ProviderSelectPaymentMethodDialogComponent,
|
||||||
@ -66,11 +66,11 @@ import { SetupComponent } from "./setup/setup.component";
|
|||||||
SetupComponent,
|
SetupComponent,
|
||||||
SetupProviderComponent,
|
SetupProviderComponent,
|
||||||
UserAddEditComponent,
|
UserAddEditComponent,
|
||||||
CreateClientOrganizationComponent,
|
CreateClientDialogComponent,
|
||||||
NoClientsComponent,
|
NoClientsComponent,
|
||||||
ManageClientOrganizationsComponent,
|
ManageClientsComponent,
|
||||||
ManageClientOrganizationNameComponent,
|
ManageClientNameDialogComponent,
|
||||||
ManageClientOrganizationSubscriptionComponent,
|
ManageClientSubscriptionDialogComponent,
|
||||||
ProviderBillingHistoryComponent,
|
ProviderBillingHistoryComponent,
|
||||||
ProviderSubscriptionComponent,
|
ProviderSubscriptionComponent,
|
||||||
ProviderSelectPaymentMethodDialogComponent,
|
ProviderSelectPaymentMethodDialogComponent,
|
||||||
|
@ -56,13 +56,15 @@
|
|||||||
<input type="text" bitInput formControlName="seats" />
|
<input type="text" bitInput formControlName="seats" />
|
||||||
<bit-hint
|
<bit-hint
|
||||||
class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2"
|
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"
|
<span class="tw-col-span-1"
|
||||||
>{{ unassignedSeatsForSelectedPlan }}
|
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
|
||||||
{{ "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-hint>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
</div>
|
</div>
|
@ -1,6 +1,6 @@
|
|||||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||||
import { Component, Inject, OnInit } from "@angular/core";
|
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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
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";
|
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||||
|
|
||||||
type CreateClientOrganizationParams = {
|
type CreateClientDialogParams = {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
plans: PlanResponse[];
|
plans: PlanResponse[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum CreateClientOrganizationResultType {
|
export enum CreateClientDialogResultType {
|
||||||
Closed = "closed",
|
Closed = "closed",
|
||||||
Submitted = "submitted",
|
Submitted = "submitted",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openCreateClientOrganizationDialog = (
|
export const openCreateClientDialog = (
|
||||||
dialogService: DialogService,
|
dialogService: DialogService,
|
||||||
dialogConfig: DialogConfig<CreateClientOrganizationParams>,
|
dialogConfig: DialogConfig<CreateClientDialogParams>,
|
||||||
) =>
|
) =>
|
||||||
dialogService.open<CreateClientOrganizationResultType, CreateClientOrganizationParams>(
|
dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>(
|
||||||
CreateClientOrganizationComponent,
|
CreateClientDialogComponent,
|
||||||
dialogConfig,
|
dialogConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -39,26 +39,24 @@ type PlanCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-create-client-organization",
|
templateUrl: "./create-client-dialog.component.html",
|
||||||
templateUrl: "./create-client-organization.component.html",
|
|
||||||
})
|
})
|
||||||
export class CreateClientOrganizationComponent implements OnInit {
|
export class CreateClientDialogComponent implements OnInit {
|
||||||
protected formGroup = this.formBuilder.group({
|
protected formGroup = new FormGroup({
|
||||||
clientOwnerEmail: ["", [Validators.required, Validators.email]],
|
clientOwnerEmail: new FormControl<string>("", [Validators.required, Validators.email]),
|
||||||
organizationName: ["", Validators.required],
|
organizationName: new FormControl<string>("", [Validators.required]),
|
||||||
seats: [null, [Validators.required, Validators.min(1)]],
|
seats: new FormControl<number>(null, [Validators.required, Validators.min(1)]),
|
||||||
});
|
});
|
||||||
protected loading = true;
|
protected loading = true;
|
||||||
protected planCards: PlanCard[];
|
protected planCards: PlanCard[];
|
||||||
protected ResultType = CreateClientOrganizationResultType;
|
protected ResultType = CreateClientDialogResultType;
|
||||||
|
|
||||||
private providerPlans: ProviderPlanResponse[];
|
private providerPlans: ProviderPlanResponse[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private billingApiService: BillingApiServiceAbstraction,
|
private billingApiService: BillingApiServiceAbstraction,
|
||||||
@Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams,
|
@Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams,
|
||||||
private dialogRef: DialogRef<CreateClientOrganizationResultType>,
|
private dialogRef: DialogRef<CreateClientDialogResultType>,
|
||||||
private formBuilder: FormBuilder,
|
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private webProviderService: WebProviderService,
|
private webProviderService: WebProviderService,
|
||||||
@ -159,14 +157,41 @@ export class CreateClientOrganizationComponent implements OnInit {
|
|||||||
this.dialogRef.close(this.ResultType.Submitted);
|
this.dialogRef.close(this.ResultType.Submitted);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected get unassignedSeatsForSelectedPlan(): number {
|
protected get openSeats(): number {
|
||||||
if (this.loading || !this.planCards) {
|
const selectedProviderPlan = this.getSelectedProviderPlan();
|
||||||
|
|
||||||
|
if (selectedProviderPlan === null) {
|
||||||
return 0;
|
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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,5 +1,5 @@
|
|||||||
export * from "./create-client-organization.component";
|
export * from "./create-client-dialog.component";
|
||||||
export * from "./manage-client-organizations.component";
|
export * from "./manage-clients.component";
|
||||||
export * from "./manage-client-organization-name.component";
|
export * from "./manage-client-name-dialog.component";
|
||||||
export * from "./manage-client-organization-subscription.component";
|
export * from "./manage-client-subscription-dialog.component";
|
||||||
export * from "./no-clients.component";
|
export * from "./no-clients.component";
|
||||||
|
@ -7,7 +7,7 @@ import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/model
|
|||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { DialogService, ToastService } from "@bitwarden/components";
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
type ManageClientOrganizationNameParams = {
|
type ManageClientNameDialogParams = {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
organization: {
|
organization: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -16,34 +16,33 @@ type ManageClientOrganizationNameParams = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ManageClientOrganizationNameResultType {
|
export enum ManageClientNameDialogResultType {
|
||||||
Closed = "closed",
|
Closed = "closed",
|
||||||
Submitted = "submitted",
|
Submitted = "submitted",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openManageClientOrganizationNameDialog = (
|
export const openManageClientNameDialog = (
|
||||||
dialogService: DialogService,
|
dialogService: DialogService,
|
||||||
dialogConfig: DialogConfig<ManageClientOrganizationNameParams>,
|
dialogConfig: DialogConfig<ManageClientNameDialogParams>,
|
||||||
) =>
|
) =>
|
||||||
dialogService.open<ManageClientOrganizationNameResultType, ManageClientOrganizationNameParams>(
|
dialogService.open<ManageClientNameDialogResultType, ManageClientNameDialogParams>(
|
||||||
ManageClientOrganizationNameComponent,
|
ManageClientNameDialogComponent,
|
||||||
dialogConfig,
|
dialogConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-manage-client-organization-name",
|
templateUrl: "manage-client-name-dialog.component.html",
|
||||||
templateUrl: "manage-client-organization-name.component.html",
|
|
||||||
})
|
})
|
||||||
export class ManageClientOrganizationNameComponent {
|
export class ManageClientNameDialogComponent {
|
||||||
protected ResultType = ManageClientOrganizationNameResultType;
|
protected ResultType = ManageClientNameDialogResultType;
|
||||||
protected formGroup = this.formBuilder.group({
|
protected formGroup = this.formBuilder.group({
|
||||||
name: [this.dialogParams.organization.name, Validators.required],
|
name: [this.dialogParams.organization.name, Validators.required],
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DIALOG_DATA) protected dialogParams: ManageClientOrganizationNameParams,
|
@Inject(DIALOG_DATA) protected dialogParams: ManageClientNameDialogParams,
|
||||||
private billingApiService: BillingApiServiceAbstraction,
|
private billingApiService: BillingApiServiceAbstraction,
|
||||||
private dialogRef: DialogRef<ManageClientOrganizationNameResultType>,
|
private dialogRef: DialogRef<ManageClientNameDialogResultType>,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
@ -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>
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,6 @@
|
|||||||
<app-header>
|
<app-header>
|
||||||
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
|
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
|
||||||
<a
|
<a type="button" bitButton *ngIf="isProviderAdmin" buttonType="primary" (click)="createClient()">
|
||||||
type="button"
|
|
||||||
bitButton
|
|
||||||
*ngIf="manageOrganizations"
|
|
||||||
buttonType="primary"
|
|
||||||
(click)="createClientOrganization()"
|
|
||||||
>
|
|
||||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||||
{{ "addNewOrganization" | i18n }}
|
{{ "addNewOrganization" | i18n }}
|
||||||
</a>
|
</a>
|
||||||
@ -73,15 +67,15 @@
|
|||||||
appA11yTitle="{{ 'options' | i18n }}"
|
appA11yTitle="{{ 'options' | i18n }}"
|
||||||
></button>
|
></button>
|
||||||
<bit-menu #rowMenu>
|
<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>
|
<i aria-hidden="true" class="bwi bwi-pencil-square"></i>
|
||||||
{{ "updateName" | i18n }}
|
{{ "updateName" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" bitMenuItem (click)="manageSubscription(client)">
|
<button type="button" bitMenuItem (click)="manageClientSubscription(client)">
|
||||||
<i aria-hidden="true" class="bwi bwi-family"></i>
|
<i aria-hidden="true" class="bwi bwi-family"></i>
|
||||||
{{ "manageSubscription" | i18n }}
|
{{ "manageSubscription" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" bitMenuItem (click)="remove(client)">
|
<button *ngIf="this.isProviderAdmin" type="button" bitMenuItem (click)="remove(client)">
|
||||||
<span class="tw-text-danger">
|
<span class="tw-text-danger">
|
||||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
|
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
|
||||||
</span>
|
</span>
|
||||||
@ -92,6 +86,9 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
<div *ngIf="clients.length === 0" class="tw-mt-10">
|
<div *ngIf="clients.length === 0" class="tw-mt-10">
|
||||||
<app-no-clients (addNewOrganizationClicked)="createClientOrganization()" />
|
<app-no-clients
|
||||||
|
[showAddOrganizationButton]="this.isProviderAdmin"
|
||||||
|
(addNewOrganizationClicked)="createClient()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
@ -10,7 +10,7 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
|||||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
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 { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@ -21,24 +21,27 @@ import { BaseClientsComponent } from "../../../admin-console/providers/clients/b
|
|||||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateClientOrganizationResultType,
|
CreateClientDialogResultType,
|
||||||
openCreateClientOrganizationDialog,
|
openCreateClientDialog,
|
||||||
} from "./create-client-organization.component";
|
} from "./create-client-dialog.component";
|
||||||
import {
|
import {
|
||||||
ManageClientOrganizationNameResultType,
|
ManageClientNameDialogResultType,
|
||||||
openManageClientOrganizationNameDialog,
|
openManageClientNameDialog,
|
||||||
} from "./manage-client-organization-name.component";
|
} from "./manage-client-name-dialog.component";
|
||||||
import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component";
|
import {
|
||||||
|
ManageClientSubscriptionDialogResultType,
|
||||||
|
openManageClientSubscriptionDialog,
|
||||||
|
} from "./manage-client-subscription-dialog.component";
|
||||||
|
|
||||||
@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;
|
providerId: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
manageOrganizations = false;
|
isProviderAdmin = false;
|
||||||
|
|
||||||
protected plans: PlanResponse[];
|
protected plans: PlanResponse[];
|
||||||
|
|
||||||
@ -73,9 +76,9 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
|||||||
switchMap((params) => {
|
switchMap((params) => {
|
||||||
this.providerId = params.providerId;
|
this.providerId = params.providerId;
|
||||||
return this.providerService.get$(this.providerId).pipe(
|
return this.providerService.get$(this.providerId).pipe(
|
||||||
canAccessBilling(this.configService),
|
hasConsolidatedBilling(this.configService),
|
||||||
map((canAccessBilling) => {
|
map((hasConsolidatedBilling) => {
|
||||||
if (!canAccessBilling) {
|
if (!hasConsolidatedBilling) {
|
||||||
return from(
|
return from(
|
||||||
this.router.navigate(["../clients"], {
|
this.router.navigate(["../clients"], {
|
||||||
relativeTo: this.activatedRoute,
|
relativeTo: this.activatedRoute,
|
||||||
@ -99,7 +102,7 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
|||||||
async load() {
|
async load() {
|
||||||
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
|
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;
|
this.clients = (await this.apiService.getProviderClients(this.providerId)).data;
|
||||||
|
|
||||||
@ -110,8 +113,23 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async manageName(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
createClient = async () => {
|
||||||
const dialogRef = openManageClientOrganizationNameDialog(this.dialogService, {
|
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: {
|
data: {
|
||||||
providerId: this.providerId,
|
providerId: this.providerId,
|
||||||
organization: {
|
organization: {
|
||||||
@ -124,38 +142,25 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
|||||||
|
|
||||||
const result = await firstValueFrom(dialogRef.closed);
|
const result = await firstValueFrom(dialogRef.closed);
|
||||||
|
|
||||||
if (result === ManageClientOrganizationNameResultType.Submitted) {
|
if (result === ManageClientNameDialogResultType.Submitted) {
|
||||||
await this.load();
|
await this.load();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
manageClientSubscription = async (
|
||||||
if (organization == null) {
|
organization: ProviderOrganizationOrganizationDetailsResponse,
|
||||||
return;
|
) => {
|
||||||
}
|
const dialogRef = openManageClientSubscriptionDialog(this.dialogService, {
|
||||||
|
|
||||||
const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, {
|
|
||||||
organization: organization,
|
|
||||||
});
|
|
||||||
|
|
||||||
await firstValueFrom(dialogRef.closed);
|
|
||||||
await this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
createClientOrganization = async () => {
|
|
||||||
const reference = openCreateClientOrganizationDialog(this.dialogService, {
|
|
||||||
data: {
|
data: {
|
||||||
providerId: this.providerId,
|
organization,
|
||||||
plans: this.plans,
|
provider: this.provider,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await lastValueFrom(reference.closed);
|
const result = await firstValueFrom(dialogRef.closed);
|
||||||
|
|
||||||
if (result === CreateClientOrganizationResultType.Closed) {
|
if (result === ManageClientSubscriptionDialogResultType.Submitted) {
|
||||||
return;
|
await this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.load();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, EventEmitter, Output } from "@angular/core";
|
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||||
|
|
||||||
import { svgIcon } from "@bitwarden/components";
|
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">
|
template: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
|
||||||
<bit-icon [icon]="icon"></bit-icon>
|
<bit-icon [icon]="icon"></bit-icon>
|
||||||
<p class="tw-mt-4">{{ "noClients" | i18n }}</p>
|
<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>
|
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||||
{{ "addNewOrganization" | i18n }}
|
{{ "addNewOrganization" | i18n }}
|
||||||
</a>
|
</a>
|
||||||
@ -34,6 +40,7 @@ const gearIcon = svgIcon`
|
|||||||
})
|
})
|
||||||
export class NoClientsComponent {
|
export class NoClientsComponent {
|
||||||
icon = gearIcon;
|
icon = gearIcon;
|
||||||
|
@Input() showAddOrganizationButton = true;
|
||||||
@Output() addNewOrganizationClicked = new EventEmitter();
|
@Output() addNewOrganizationClicked = new EventEmitter();
|
||||||
|
|
||||||
addNewOrganization = () => this.addNewOrganizationClicked.emit();
|
addNewOrganization = () => this.addNewOrganizationClicked.emit();
|
||||||
|
@ -20,7 +20,6 @@ export const hasConsolidatedBilling: CanActivateFn = async (route: ActivatedRout
|
|||||||
if (
|
if (
|
||||||
!consolidatedBillingEnabled ||
|
!consolidatedBillingEnabled ||
|
||||||
!provider ||
|
!provider ||
|
||||||
!provider.isProviderAdmin ||
|
|
||||||
provider.providerStatus !== ProviderStatusType.Billable
|
provider.providerStatus !== ProviderStatusType.Billable
|
||||||
) {
|
) {
|
||||||
return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]);
|
return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]);
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<ng-container header>
|
<ng-container header>
|
||||||
<tr>
|
<tr>
|
||||||
<th bitCell>{{ "date" | i18n }}</th>
|
<th bitCell>{{ "date" | i18n }}</th>
|
||||||
<th bitCell>{{ "invoiceNumberHeader" | i18n }}</th>
|
<th bitCell>{{ "invoice" | i18n }}</th>
|
||||||
<th bitCell>{{ "total" | i18n }}</th>
|
<th bitCell>{{ "total" | i18n }}</th>
|
||||||
<th bitCell>{{ "status" | i18n }}</th>
|
<th bitCell>{{ "status" | i18n }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -29,7 +29,23 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td bitCell>{{ invoice.total | currency: "$" }}</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>
|
<td bitCell>
|
||||||
<button
|
<button
|
||||||
[bitMenuTriggerFor]="rowMenu"
|
[bitMenuTriggerFor]="rowMenu"
|
||||||
|
@ -46,4 +46,19 @@ export class InvoicesComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.loading = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
|||||||
|
|
||||||
type MaybeProvider = Provider | undefined;
|
type MaybeProvider = Provider | undefined;
|
||||||
|
|
||||||
export const canAccessBilling = (
|
export const hasConsolidatedBilling = (
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
): OperatorFunction<MaybeProvider, boolean> =>
|
): OperatorFunction<MaybeProvider, boolean> =>
|
||||||
switchMap<MaybeProvider, Observable<boolean>>((provider) =>
|
switchMap<MaybeProvider, Observable<boolean>>((provider) =>
|
||||||
@ -16,9 +16,7 @@ export const canAccessBilling = (
|
|||||||
.pipe(
|
.pipe(
|
||||||
map((consolidatedBillingEnabled) =>
|
map((consolidatedBillingEnabled) =>
|
||||||
provider
|
provider
|
||||||
? provider.isProviderAdmin &&
|
? provider.providerStatus === ProviderStatusType.Billable && consolidatedBillingEnabled
|
||||||
provider.providerStatus === ProviderStatusType.Billable &&
|
|
||||||
consolidatedBillingEnabled
|
|
||||||
: false,
|
: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -18,6 +18,7 @@ export class InvoiceResponse extends BaseResponse {
|
|||||||
number: string;
|
number: string;
|
||||||
total: number;
|
total: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
dueDate: string;
|
||||||
url: string;
|
url: string;
|
||||||
pdfUrl: string;
|
pdfUrl: string;
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ export class InvoiceResponse extends BaseResponse {
|
|||||||
this.number = this.getResponseProperty("Number");
|
this.number = this.getResponseProperty("Number");
|
||||||
this.total = this.getResponseProperty("Total");
|
this.total = this.getResponseProperty("Total");
|
||||||
this.status = this.getResponseProperty("Status");
|
this.status = this.getResponseProperty("Status");
|
||||||
|
this.dueDate = this.getResponseProperty("DueDate");
|
||||||
this.url = this.getResponseProperty("Url");
|
this.url = this.getResponseProperty("Url");
|
||||||
this.pdfUrl = this.getResponseProperty("PdfUrl");
|
this.pdfUrl = this.getResponseProperty("PdfUrl");
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user