mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[PM-21821] Provider portal takeover states (#15725)
* Updates: - Update simple dialog to disallow user to close the dialog on acceptance - Split payment components to provide a "require" component that cannot be closed out of - Add provider warning service to manage the various provider warnings * Fix test * Will's feedback and sync on payment method success
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||||
import { Component, Inject, ViewChild } from "@angular/core";
|
import { Component, Inject } from "@angular/core";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||||
@@ -7,19 +7,17 @@ import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden
|
|||||||
import { SharedModule } from "../../../shared";
|
import { SharedModule } from "../../../shared";
|
||||||
import { BillingClient } from "../../services";
|
import { BillingClient } from "../../services";
|
||||||
import { BillableEntity } from "../../types";
|
import { BillableEntity } from "../../types";
|
||||||
import { MaskedPaymentMethod } from "../types";
|
|
||||||
|
|
||||||
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||||
|
import {
|
||||||
|
SubmitPaymentMethodDialogComponent,
|
||||||
|
SubmitPaymentMethodDialogResult,
|
||||||
|
} from "./submit-payment-method-dialog.component";
|
||||||
|
|
||||||
type DialogParams = {
|
type DialogParams = {
|
||||||
owner: BillableEntity;
|
owner: BillableEntity;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DialogResult =
|
|
||||||
| { type: "cancelled" }
|
|
||||||
| { type: "error" }
|
|
||||||
| { type: "success"; paymentMethod: MaskedPaymentMethod };
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
@@ -55,63 +53,23 @@ type DialogResult =
|
|||||||
imports: [EnterPaymentMethodComponent, SharedModule],
|
imports: [EnterPaymentMethodComponent, SharedModule],
|
||||||
providers: [BillingClient],
|
providers: [BillingClient],
|
||||||
})
|
})
|
||||||
export class ChangePaymentMethodDialogComponent {
|
export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
|
||||||
@ViewChild(EnterPaymentMethodComponent)
|
protected override owner: BillableEntity;
|
||||||
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
|
||||||
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private billingClient: BillingClient,
|
billingClient: BillingClient,
|
||||||
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
|
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
|
||||||
private dialogRef: DialogRef<DialogResult>,
|
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
|
||||||
private i18nService: I18nService,
|
i18nService: I18nService,
|
||||||
private toastService: ToastService,
|
toastService: ToastService,
|
||||||
) {}
|
) {
|
||||||
|
super(billingClient, dialogRef, i18nService, toastService);
|
||||||
submit = async () => {
|
this.owner = this.dialogParams.owner;
|
||||||
this.formGroup.markAllAsTouched();
|
|
||||||
|
|
||||||
if (!this.formGroup.valid) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
|
||||||
const billingAddress =
|
|
||||||
this.formGroup.value.type !== "payPal"
|
|
||||||
? this.formGroup.controls.billingAddress.getRawValue()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const result = await this.billingClient.updatePaymentMethod(
|
|
||||||
this.dialogParams.owner,
|
|
||||||
paymentMethod,
|
|
||||||
billingAddress,
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (result.type) {
|
|
||||||
case "success": {
|
|
||||||
this.toastService.showToast({
|
|
||||||
variant: "success",
|
|
||||||
title: "",
|
|
||||||
message: this.i18nService.t("paymentMethodUpdated"),
|
|
||||||
});
|
|
||||||
this.dialogRef.close({
|
|
||||||
type: "success",
|
|
||||||
paymentMethod: result.value,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "error": {
|
|
||||||
this.toastService.showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: "",
|
|
||||||
message: result.message,
|
|
||||||
});
|
|
||||||
this.dialogRef.close({ type: "error" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
|
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
|
||||||
dialogService.open<DialogResult>(ChangePaymentMethodDialogComponent, dialogConfig);
|
dialogService.open<SubmitPaymentMethodDialogResult>(
|
||||||
|
ChangePaymentMethodDialogComponent,
|
||||||
|
dialogConfig,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export * from "./display-payment-method.component";
|
|||||||
export * from "./edit-billing-address-dialog.component";
|
export * from "./edit-billing-address-dialog.component";
|
||||||
export * from "./enter-billing-address.component";
|
export * from "./enter-billing-address.component";
|
||||||
export * from "./enter-payment-method.component";
|
export * from "./enter-payment-method.component";
|
||||||
|
export * from "./require-payment-method-dialog.component";
|
||||||
|
export * from "./submit-payment-method-dialog.component";
|
||||||
export * from "./verify-bank-account.component";
|
export * from "./verify-bank-account.component";
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||||
|
import { Component, Inject } from "@angular/core";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import {
|
||||||
|
CalloutTypes,
|
||||||
|
DialogConfig,
|
||||||
|
DialogRef,
|
||||||
|
DialogService,
|
||||||
|
ToastService,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { SharedModule } from "../../../shared";
|
||||||
|
import { BillingClient } from "../../services";
|
||||||
|
import { BillableEntity } from "../../types";
|
||||||
|
|
||||||
|
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||||
|
import {
|
||||||
|
SubmitPaymentMethodDialogComponent,
|
||||||
|
SubmitPaymentMethodDialogResult,
|
||||||
|
} from "./submit-payment-method-dialog.component";
|
||||||
|
|
||||||
|
type DialogParams = {
|
||||||
|
owner: BillableEntity;
|
||||||
|
callout: {
|
||||||
|
type: CalloutTypes;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
|
<bit-dialog>
|
||||||
|
<span bitDialogTitle class="tw-font-semibold">
|
||||||
|
{{ "addPaymentMethod" | i18n }}
|
||||||
|
</span>
|
||||||
|
<div bitDialogContent>
|
||||||
|
<bit-callout [type]="dialogParams.callout.type" [title]="dialogParams.callout.title">
|
||||||
|
{{ dialogParams.callout.message }}
|
||||||
|
</bit-callout>
|
||||||
|
<app-enter-payment-method [group]="formGroup" [includeBillingAddress]="true">
|
||||||
|
</app-enter-payment-method>
|
||||||
|
</div>
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button bitButton bitFormButton buttonType="primary" type="submit">
|
||||||
|
{{ "save" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</bit-dialog>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
standalone: true,
|
||||||
|
imports: [EnterPaymentMethodComponent, SharedModule],
|
||||||
|
providers: [BillingClient],
|
||||||
|
})
|
||||||
|
export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
|
||||||
|
protected override owner: BillableEntity;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
billingClient: BillingClient,
|
||||||
|
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
|
||||||
|
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
|
||||||
|
i18nService: I18nService,
|
||||||
|
toastService: ToastService,
|
||||||
|
) {
|
||||||
|
super(billingClient, dialogRef, i18nService, toastService);
|
||||||
|
this.owner = this.dialogParams.owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
|
||||||
|
dialogService.open<SubmitPaymentMethodDialogResult>(RequirePaymentMethodDialogComponent, {
|
||||||
|
...dialogConfig,
|
||||||
|
disableClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Component, ViewChild } from "@angular/core";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { DialogRef, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { BillingClient } from "../../services";
|
||||||
|
import { BillableEntity } from "../../types";
|
||||||
|
import { MaskedPaymentMethod } from "../types";
|
||||||
|
|
||||||
|
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||||
|
|
||||||
|
export type SubmitPaymentMethodDialogResult =
|
||||||
|
| { type: "cancelled" }
|
||||||
|
| { type: "error" }
|
||||||
|
| { type: "success"; paymentMethod: MaskedPaymentMethod };
|
||||||
|
|
||||||
|
@Component({ template: "" })
|
||||||
|
export abstract class SubmitPaymentMethodDialogComponent {
|
||||||
|
@ViewChild(EnterPaymentMethodComponent)
|
||||||
|
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||||
|
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
|
||||||
|
|
||||||
|
protected abstract owner: BillableEntity;
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
protected billingClient: BillingClient,
|
||||||
|
protected dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
|
||||||
|
protected i18nService: I18nService,
|
||||||
|
protected toastService: ToastService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
submit = async () => {
|
||||||
|
this.formGroup.markAllAsTouched();
|
||||||
|
|
||||||
|
if (!this.formGroup.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||||
|
const billingAddress =
|
||||||
|
this.formGroup.value.type !== "payPal"
|
||||||
|
? this.formGroup.controls.billingAddress.getRawValue()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await this.billingClient.updatePaymentMethod(
|
||||||
|
this.owner,
|
||||||
|
paymentMethod,
|
||||||
|
billingAddress,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (result.type) {
|
||||||
|
case "success": {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "",
|
||||||
|
message: this.i18nService.t("paymentMethodUpdated"),
|
||||||
|
});
|
||||||
|
this.dialogRef.close({
|
||||||
|
type: "success",
|
||||||
|
paymentMethod: result.value,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
this.dialogRef.close({ type: "error" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10922,6 +10922,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"unpaidInvoices": {
|
||||||
|
"message": "Unpaid invoices"
|
||||||
|
},
|
||||||
|
"unpaidInvoicesForServiceUser": {
|
||||||
|
"message": "Your subscription has not been paid. Contact your provider administrator to restore service to you and your clients.",
|
||||||
|
"description": "A message shown in a non-dismissible dialog to service users of unpaid providers."
|
||||||
|
},
|
||||||
|
"providerSuspended": {
|
||||||
|
"message": "$PROVIDER$ is suspended",
|
||||||
|
"placeholders": {
|
||||||
|
"provider": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "Acme Industries"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"restoreProviderPortalAccessViaCustomerSupport": {
|
||||||
|
"message": "To restore access to your provider portal, contact Bitwarden Customer Support to renew your subscription.",
|
||||||
|
"description": "A message shown in a non-dismissible dialog to any user of a suspended providers."
|
||||||
|
},
|
||||||
|
"restoreProviderPortalAccessViaPaymentMethod": {
|
||||||
|
"message": "Your subscription has not been paid. To restore service to you and your clients, add a payment method by $CANCELLATION_DATE$.",
|
||||||
|
"placeholders": {
|
||||||
|
"cancellation_date": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "07/10/2025"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "A message shown in a non-dismissible dialog to admins of unpaid providers."
|
||||||
|
},
|
||||||
"subscribetoEnterprise": {
|
"subscribetoEnterprise": {
|
||||||
"message": "Subscribe to $PLAN$",
|
"message": "Subscribe to $PLAN$",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/i
|
|||||||
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
|
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
|
||||||
import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
|
import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
|
||||||
|
|
||||||
|
import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "providers-layout",
|
selector: "providers-layout",
|
||||||
templateUrl: "providers-layout.component.html",
|
templateUrl: "providers-layout.component.html",
|
||||||
imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule],
|
imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule],
|
||||||
|
providers: [ProviderWarningsService],
|
||||||
})
|
})
|
||||||
export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||||
protected readonly logo = ProviderPortalLogo;
|
protected readonly logo = ProviderPortalLogo;
|
||||||
@@ -40,13 +43,18 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private providerService: ProviderService,
|
private providerService: ProviderService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private providerWarningsService: ProviderWarningsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
document.body.classList.remove("layout_frontend");
|
document.body.classList.remove("layout_frontend");
|
||||||
|
|
||||||
this.provider$ = this.route.params.pipe(
|
const providerId$: Observable<string> = this.route.params.pipe(
|
||||||
switchMap((params) => this.providerService.get$(params.providerId)),
|
map((params) => params.providerId),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.provider$ = providerId$.pipe(
|
||||||
|
switchMap((providerId) => this.providerService.get$(providerId)),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,6 +85,15 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$(
|
this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$(
|
||||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
providerId$
|
||||||
|
.pipe(
|
||||||
|
switchMap((providerId) =>
|
||||||
|
this.providerWarningsService.showProviderSuspendedDialog$(providerId),
|
||||||
|
),
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { TestBed } from "@angular/core/testing";
|
||||||
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||||
|
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||||
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
|
import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
|
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||||
|
import {
|
||||||
|
RequirePaymentMethodDialogComponent,
|
||||||
|
SubmitPaymentMethodDialogResult,
|
||||||
|
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||||
|
|
||||||
|
import { ProviderWarningsService } from "./provider-warnings.service";
|
||||||
|
|
||||||
|
describe("ProviderWarningsService", () => {
|
||||||
|
let service: ProviderWarningsService;
|
||||||
|
let configService: MockProxy<ConfigService>;
|
||||||
|
let dialogService: MockProxy<DialogService>;
|
||||||
|
let providerService: MockProxy<ProviderService>;
|
||||||
|
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
|
||||||
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
let router: MockProxy<Router>;
|
||||||
|
let syncService: MockProxy<SyncService>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
configService = mock<ConfigService>();
|
||||||
|
dialogService = mock<DialogService>();
|
||||||
|
i18nService = mock<I18nService>();
|
||||||
|
providerService = mock<ProviderService>();
|
||||||
|
router = mock<Router>();
|
||||||
|
syncService = mock<SyncService>();
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
ProviderWarningsService,
|
||||||
|
{ provide: ActivatedRoute, useValue: {} },
|
||||||
|
{ provide: BillingApiServiceAbstraction, useValue: billingApiService },
|
||||||
|
{ provide: ConfigService, useValue: configService },
|
||||||
|
{ provide: DialogService, useValue: dialogService },
|
||||||
|
{ provide: I18nService, useValue: i18nService },
|
||||||
|
{ provide: ProviderService, useValue: providerService },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: SyncService, useValue: syncService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(ProviderWarningsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create the service", () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showProviderSuspendedDialog$", () => {
|
||||||
|
const providerId = "test-provider-id";
|
||||||
|
|
||||||
|
it("should not show any dialog when the 'pm-21821-provider-portal-takeover' flag is disabled", (done) => {
|
||||||
|
const provider = { enabled: false } as Provider;
|
||||||
|
const subscription = { status: "unpaid" } as ProviderSubscriptionResponse;
|
||||||
|
|
||||||
|
providerService.get$.mockReturnValue(of(provider));
|
||||||
|
billingApiService.getProviderSubscription.mockResolvedValue(subscription);
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
|
const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn(
|
||||||
|
RequirePaymentMethodDialogComponent,
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
|
||||||
|
service.showProviderSuspendedDialog$(providerId).subscribe(() => {
|
||||||
|
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||||
|
expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show any dialog when the provider is enabled", (done) => {
|
||||||
|
const provider = { enabled: true } as Provider;
|
||||||
|
const subscription = { status: "unpaid" } as ProviderSubscriptionResponse;
|
||||||
|
|
||||||
|
providerService.get$.mockReturnValue(of(provider));
|
||||||
|
billingApiService.getProviderSubscription.mockResolvedValue(subscription);
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn(
|
||||||
|
RequirePaymentMethodDialogComponent,
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
|
||||||
|
service.showProviderSuspendedDialog$(providerId).subscribe(() => {
|
||||||
|
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||||
|
expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the require payment method dialog for an admin of a provider with an unpaid subscription", (done) => {
|
||||||
|
const provider = {
|
||||||
|
enabled: false,
|
||||||
|
type: ProviderUserType.ProviderAdmin,
|
||||||
|
name: "Test Provider",
|
||||||
|
} as Provider;
|
||||||
|
const subscription = {
|
||||||
|
status: "unpaid",
|
||||||
|
cancelAt: "2024-12-31",
|
||||||
|
} as ProviderSubscriptionResponse;
|
||||||
|
|
||||||
|
providerService.get$.mockReturnValue(of(provider));
|
||||||
|
billingApiService.getProviderSubscription.mockResolvedValue(subscription);
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
const dialogRef = {
|
||||||
|
closed: of({ type: "success" }),
|
||||||
|
} as DialogRef<SubmitPaymentMethodDialogResult>;
|
||||||
|
jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(dialogRef);
|
||||||
|
|
||||||
|
service.showProviderSuspendedDialog$(providerId).subscribe(() => {
|
||||||
|
expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalled();
|
||||||
|
expect(syncService.fullSync).toHaveBeenCalled();
|
||||||
|
expect(router.navigate).toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the simple, unpaid invoices dialog for a service user of a provider with an unpaid subscription", (done) => {
|
||||||
|
const provider = {
|
||||||
|
enabled: false,
|
||||||
|
type: ProviderUserType.ServiceUser,
|
||||||
|
name: "Test Provider",
|
||||||
|
} as Provider;
|
||||||
|
const subscription = { status: "unpaid" } as ProviderSubscriptionResponse;
|
||||||
|
|
||||||
|
providerService.get$.mockReturnValue(of(provider));
|
||||||
|
billingApiService.getProviderSubscription.mockResolvedValue(subscription);
|
||||||
|
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
i18nService.t.mockImplementation((key: string) => key);
|
||||||
|
|
||||||
|
service.showProviderSuspendedDialog$(providerId).subscribe(() => {
|
||||||
|
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
type: "danger",
|
||||||
|
title: "unpaidInvoices",
|
||||||
|
content: "unpaidInvoicesForServiceUser",
|
||||||
|
disableClose: true,
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the provider suspended dialog to all users of a provider that's suspended, but not unpaid", (done) => {
|
||||||
|
const provider = {
|
||||||
|
enabled: false,
|
||||||
|
name: "Test Provider",
|
||||||
|
} as Provider;
|
||||||
|
const subscription = { status: "active" } as ProviderSubscriptionResponse;
|
||||||
|
|
||||||
|
providerService.get$.mockReturnValue(of(provider));
|
||||||
|
billingApiService.getProviderSubscription.mockResolvedValue(subscription);
|
||||||
|
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
i18nService.t.mockImplementation((key: string) => key);
|
||||||
|
|
||||||
|
service.showProviderSuspendedDialog$(providerId).subscribe(() => {
|
||||||
|
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
type: "danger",
|
||||||
|
title: "providerSuspended",
|
||||||
|
content: "restoreProviderPortalAccessViaCustomerSupport",
|
||||||
|
disableClose: true,
|
||||||
|
acceptButtonText: "contactSupportShort",
|
||||||
|
cancelButtonText: null,
|
||||||
|
acceptAction: expect.any(Function),
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
|
import { combineLatest, from, lastValueFrom, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||||
|
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProviderWarningsService {
|
||||||
|
constructor(
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private billingApiService: BillingApiServiceAbstraction,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private providerService: ProviderService,
|
||||||
|
private router: Router,
|
||||||
|
private syncService: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
showProviderSuspendedDialog$ = (providerId: string): Observable<void> =>
|
||||||
|
combineLatest([
|
||||||
|
this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover),
|
||||||
|
this.providerService.get$(providerId),
|
||||||
|
from(this.billingApiService.getProviderSubscription(providerId)),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(async ([providerPortalTakeover, provider, subscription]) => {
|
||||||
|
if (!providerPortalTakeover || provider.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status === "unpaid") {
|
||||||
|
switch (provider.type) {
|
||||||
|
case ProviderUserType.ProviderAdmin: {
|
||||||
|
const cancelAt = subscription.cancelAt
|
||||||
|
? new Date(subscription.cancelAt).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dialogRef = RequirePaymentMethodDialogComponent.open(this.dialogService, {
|
||||||
|
data: {
|
||||||
|
owner: {
|
||||||
|
type: "provider",
|
||||||
|
data: provider,
|
||||||
|
},
|
||||||
|
callout: {
|
||||||
|
type: "danger",
|
||||||
|
title: this.i18nService.t("unpaidInvoices"),
|
||||||
|
message: this.i18nService.t(
|
||||||
|
"restoreProviderPortalAccessViaPaymentMethod",
|
||||||
|
cancelAt ?? undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await lastValueFrom(dialogRef.closed);
|
||||||
|
|
||||||
|
if (result?.type === "success") {
|
||||||
|
await this.syncService.fullSync(true);
|
||||||
|
await this.router.navigate(["."], {
|
||||||
|
relativeTo: this.activatedRoute,
|
||||||
|
onSameUrlNavigation: "reload",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ProviderUserType.ServiceUser: {
|
||||||
|
await this.dialogService.openSimpleDialog({
|
||||||
|
type: "danger",
|
||||||
|
title: this.i18nService.t("unpaidInvoices"),
|
||||||
|
content: this.i18nService.t("unpaidInvoicesForServiceUser"),
|
||||||
|
disableClose: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.dialogService.openSimpleDialog({
|
||||||
|
type: "danger",
|
||||||
|
title: this.i18nService.t("providerSuspended", provider.name),
|
||||||
|
content: this.i18nService.t("restoreProviderPortalAccessViaCustomerSupport"),
|
||||||
|
disableClose: true,
|
||||||
|
acceptButtonText: this.i18nService.t("contactSupportShort"),
|
||||||
|
cancelButtonText: null,
|
||||||
|
acceptAction: async () => {
|
||||||
|
window.open("https://bitwarden.com/contact/", "_blank");
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ export enum FeatureFlag {
|
|||||||
UseOrganizationWarningsService = "use-organization-warnings-service",
|
UseOrganizationWarningsService = "use-organization-warnings-service",
|
||||||
AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
|
AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
|
||||||
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
|
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
|
||||||
|
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||||
|
|
||||||
/* Key Management */
|
/* Key Management */
|
||||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||||
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
|
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
|
||||||
[FeatureFlag.AllowTrialLengthZero]: FALSE,
|
[FeatureFlag.AllowTrialLengthZero]: FALSE,
|
||||||
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
|
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
|
||||||
|
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||||
|
|
||||||
/* Key Management */
|
/* Key Management */
|
||||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export class SimpleConfigurableDialogComponent {
|
|||||||
await this.simpleDialogOpts.acceptAction();
|
await this.simpleDialogOpts.acceptAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.simpleDialogOpts.disableClose) {
|
||||||
this.dialogRef.close(true);
|
this.dialogRef.close(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private localizeText() {
|
private localizeText() {
|
||||||
|
|||||||
Reference in New Issue
Block a user