1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +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:
Alex Morask
2025-07-28 09:26:19 -05:00
committed by GitHub
parent 38d5edc2c5
commit f4254ba920
10 changed files with 518 additions and 64 deletions

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