[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:
Alex Morask 2025-07-28 09:26:19 -05:00 committed by GitHub
parent 38d5edc2c5
commit f4254ba920
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 518 additions and 64 deletions

View File

@ -1,5 +1,5 @@
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 { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
@ -7,19 +7,17 @@ import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden
import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services";
import { BillableEntity } from "../../types";
import { MaskedPaymentMethod } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
import {
SubmitPaymentMethodDialogComponent,
SubmitPaymentMethodDialogResult,
} from "./submit-payment-method-dialog.component";
type DialogParams = {
owner: BillableEntity;
};
type DialogResult =
| { type: "cancelled" }
| { type: "error" }
| { type: "success"; paymentMethod: MaskedPaymentMethod };
@Component({
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
@ -55,63 +53,23 @@ type DialogResult =
imports: [EnterPaymentMethodComponent, SharedModule],
providers: [BillingClient],
})
export class ChangePaymentMethodDialogComponent {
@ViewChild(EnterPaymentMethodComponent)
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
protected override owner: BillableEntity;
constructor(
private billingClient: BillingClient,
billingClient: BillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
private dialogRef: DialogRef<DialogResult>,
private i18nService: I18nService,
private 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.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;
}
}
};
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<DialogResult>(ChangePaymentMethodDialogComponent, dialogConfig);
dialogService.open<SubmitPaymentMethodDialogResult>(
ChangePaymentMethodDialogComponent,
dialogConfig,
);
}

View File

@ -6,4 +6,6 @@ export * from "./display-payment-method.component";
export * from "./edit-billing-address-dialog.component";
export * from "./enter-billing-address.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";

View File

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

View File

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

View File

@ -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": {
"message": "Subscribe to $PLAN$",
"placeholders": {

View File

@ -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 { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service";
@Component({
selector: "providers-layout",
templateUrl: "providers-layout.component.html",
imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule],
providers: [ProviderWarningsService],
})
export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected readonly logo = ProviderPortalLogo;
@ -40,13 +43,18 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private providerService: ProviderService,
private configService: ConfigService,
private providerWarningsService: ProviderWarningsService,
) {}
ngOnInit() {
document.body.classList.remove("layout_frontend");
this.provider$ = this.route.params.pipe(
switchMap((params) => this.providerService.get$(params.providerId)),
const providerId$: Observable<string> = this.route.params.pipe(
map((params) => params.providerId),
);
this.provider$ = providerId$.pipe(
switchMap((providerId) => this.providerService.get$(providerId)),
takeUntil(this.destroy$),
);
@ -77,6 +85,15 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
providerId$
.pipe(
switchMap((providerId) =>
this.providerWarningsService.showProviderSuspendedDialog$(providerId),
),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {

View File

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

View File

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

View File

@ -32,6 +32,7 @@ export enum FeatureFlag {
UseOrganizationWarningsService = "use-organization-warnings-service",
AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
[FeatureFlag.AllowTrialLengthZero]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@ -68,7 +68,9 @@ export class SimpleConfigurableDialogComponent {
await this.simpleDialogOpts.acceptAction();
}
this.dialogRef.close(true);
if (!this.simpleDialogOpts.disableClose) {
this.dialogRef.close(true);
}
};
private localizeText() {