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:
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user