Browse Source
* 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 successpull/15815/head
10 changed files with 518 additions and 64 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
@ -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(); |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
}), |
||||||
|
); |
||||||
|
} |
||||||
Loading…
Reference in new issue