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