[AC-1758] Show banner when organization requires a payment method (#7088)

* Add billing banner states to account settings

* Add billing banner service

* Add add-payment-method-banners.component

* Use add-payment-method-banners.component in layouts

* Clear banner on payment method addition

* Ran prettier after CI update

* Finalize banners styling/translations

* Will's (non-Tailwind) feedback

* Review feedback

* Review feedback

* Review feedback

* Replace StateService with StateProvider in BillingBannerService

* Remove StateService methods
This commit is contained in:
Alex Morask 2024-01-23 12:47:52 -05:00 committed by GitHub
parent 4475f67bbc
commit 014281cb93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 208 additions and 8 deletions

View File

@ -38,7 +38,7 @@ import {
],
})
export class Fido2UseBrowserLinkComponent {
showOverlay: boolean = false;
showOverlay = false;
isOpen = false;
overlayPosition: ConnectedPosition[] = [
{

View File

@ -1,4 +1,5 @@
<app-navbar></app-navbar>
<app-payment-method-banners></app-payment-method-banners>
<div class="org-nav !tw-h-32" *ngIf="organization$ | async as organization">
<div class="container d-flex">
<div class="d-flex flex-column">
@ -36,6 +37,5 @@
</div>
</div>
</div>
<router-outlet></router-outlet>
<app-footer></app-footer>

View File

@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -33,6 +34,7 @@ export class AdjustPaymentComponent {
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private billingBannerService: BillingBannerServiceAbstraction,
) {}
async submit() {
@ -56,6 +58,9 @@ export class AdjustPaymentComponent {
}
});
await this.formPromise;
if (this.organizationId) {
await this.billingBannerService.setPaymentMethodBannerState(this.organizationId, false);
}
this.platformUtilsService.showToast(
"success",
null,

View File

@ -0,0 +1,15 @@
<ng-container *ngFor="let banner of banners$ | async">
<bit-banner
*ngIf="banner.visible"
bannerType="warning"
(onClose)="closeBanner(banner.organizationId)"
>
{{ "maintainYourSubscription" | i18n: banner.organizationName }}
<a
bitLink
linkType="contrast"
[routerLink]="['/organizations', banner.organizationId, 'billing', 'payment-method']"
>{{ "addAPaymentMethod" | i18n }}</a
>.
</bit-banner>
</ng-container>

View File

@ -0,0 +1,76 @@
import { Component } from "@angular/core";
import { combineLatest, Observable, switchMap } from "rxjs";
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
OrganizationService,
canAccessAdmin,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BannerModule } from "@bitwarden/components";
import { SharedModule } from "../../shared/shared.module";
type PaymentMethodBannerData = {
organizationId: string;
organizationName: string;
visible: boolean;
};
@Component({
standalone: true,
selector: "app-payment-method-banners",
templateUrl: "payment-method-banners.component.html",
imports: [BannerModule, SharedModule],
})
export class PaymentMethodBannersComponent {
constructor(
private billingBannerService: BillingBannerServiceAbstraction,
private i18nService: I18nService,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiService,
) {}
private organizations$ = this.organizationService.memberOrganizations$.pipe(
canAccessAdmin(this.i18nService),
);
protected banners$: Observable<PaymentMethodBannerData[]> = combineLatest([
this.organizations$,
this.billingBannerService.paymentMethodBannerStates$,
]).pipe(
switchMap(async ([organizations, paymentMethodBannerStates]) => {
return await Promise.all(
organizations.map(async (organization) => {
const matchingBanner = paymentMethodBannerStates.find(
(banner) => banner.organizationId === organization.id,
);
if (matchingBanner !== null && matchingBanner !== undefined) {
return {
organizationId: organization.id,
organizationName: organization.name,
visible: matchingBanner.visible,
};
}
const response = await this.organizationApiService.risksSubscriptionFailure(
organization.id,
);
await this.billingBannerService.setPaymentMethodBannerState(
organization.id,
response.risksSubscriptionFailure,
);
return {
organizationId: organization.id,
organizationName: organization.name,
visible: response.risksSubscriptionFailure,
};
}),
);
}),
);
protected async closeBanner(organizationId: string): Promise<void> {
await this.billingBannerService.setPaymentMethodBannerState(organizationId, false);
}
}

View File

@ -1,3 +1,4 @@
<app-navbar></app-navbar>
<app-payment-method-banners></app-payment-method-banners>
<router-outlet></router-outlet>
<app-footer></app-footer>

View File

@ -60,6 +60,7 @@ import { UpdateTempPasswordComponent } from "../auth/update-temp-password.compon
import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component";
import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component";
import { DynamicAvatarComponent } from "../components/dynamic-avatar.component";
import { PaymentMethodBannersComponent } from "../components/payment-method-banners/payment-method-banners.component";
import { SelectableAvatarComponent } from "../components/selectable-avatar.component";
import { FooterComponent } from "../layouts/footer.component";
import { FrontendLayoutComponent } from "../layouts/frontend-layout.component";
@ -109,6 +110,7 @@ import { SharedModule } from "./shared.module";
PipesModule,
PasswordCalloutComponent,
DangerZoneComponent,
PaymentMethodBannersComponent,
],
declarations: [
AcceptFamilySponsorshipComponent,

View File

@ -7392,12 +7392,6 @@
"skipToContent": {
"message": "Skip to content"
},
"customBillingStart": {
"message": "Custom billing is not reflected. Visit the "
},
"customBillingEnd": {
"message": " page for latest invoicing."
},
"managePermissionRequired": {
"message": "At least one member or group must have can manage permission."
},
@ -7453,5 +7447,19 @@
"commonImportFormats": {
"message": "Common formats",
"description": "Label indicating the most common import formats"
},
"maintainYourSubscription": {
"message": "To maintain your subscription for $ORG$, ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'",
"placeholders": {
"org": {
"content": "$1",
"example": "Example Inc."
}
}
},
"addAPaymentMethod": {
"message": "add a payment method",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'"
}
}

View File

@ -1,4 +1,5 @@
<app-navbar></app-navbar>
<app-payment-method-banners></app-payment-method-banners>
<div class="org-nav" *ngIf="provider">
<div class="container d-flex">
<div class="d-flex flex-column">

View File

@ -5,6 +5,7 @@ import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchModule } from "@bitwarden/components";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import { PaymentMethodBannersComponent } from "@bitwarden/web-vault/app/components/payment-method-banners/payment-method-banners.component";
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import { AddOrganizationComponent } from "./clients/add-organization.component";
@ -34,6 +35,7 @@ import { SetupComponent } from "./setup/setup.component";
JslibModule,
ProvidersRoutingModule,
OrganizationPlansComponent,
PaymentMethodBannersComponent,
SearchModule,
],
declarations: [

View File

@ -1,4 +1,5 @@
<app-navbar></app-navbar>
<app-payment-method-banners></app-payment-method-banners>
<div class="container page-content">
<div class="page-header">
<h1>{{ "setupProvider" | i18n }}</h1>

View File

@ -75,6 +75,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/services/user-ve
import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service";
import { WebAuthnLoginPrfCryptoService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-crypto.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction";
import { BillingBannerService } from "@bitwarden/common/billing/services/billing-banner.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
@ -834,6 +836,11 @@ import { ModalService } from "./modal.service";
DerivedStateProvider,
],
},
{
provide: BillingBannerServiceAbstraction,
useClass: BillingBannerService,
deps: [StateProvider],
},
],
})
export class JslibServicesModule {}

View File

@ -9,6 +9,7 @@ import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/reques
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationRisksSubscriptionFailureResponse } from "../../../billing/models/response/organization-risks-subscription-failure.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
@ -78,4 +79,5 @@ export class OrganizationApiServiceAbstraction {
id: string,
request: OrganizationCollectionManagementUpdateRequest,
) => Promise<OrganizationResponse>;
risksSubscriptionFailure: (id: string) => Promise<OrganizationRisksSubscriptionFailureResponse>;
}

View File

@ -10,6 +10,7 @@ import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/reques
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationRisksSubscriptionFailureResponse } from "../../../billing/models/response/organization-risks-subscription-failure.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
@ -342,4 +343,18 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
await this.syncService.fullSync(true);
return data;
}
async risksSubscriptionFailure(
id: string,
): Promise<OrganizationRisksSubscriptionFailureResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + id + "/risks-subscription-failure",
null,
true,
true,
);
return new OrganizationRisksSubscriptionFailureResponse(r);
}
}

View File

@ -0,0 +1,6 @@
import { Observable } from "rxjs";
export class BillingBannerServiceAbstraction {
paymentMethodBannerStates$: Observable<{ organizationId: string; visible: boolean }[]>;
setPaymentMethodBannerState: (organizationId: string, visible: boolean) => Promise<void>;
}

View File

@ -0,0 +1,13 @@
import { BaseResponse } from "../../../models/response/base.response";
export class OrganizationRisksSubscriptionFailureResponse extends BaseResponse {
organizationId: string;
risksSubscriptionFailure: boolean;
constructor(response: any) {
super(response);
this.organizationId = this.getResponseProperty("OrganizationId");
this.risksSubscriptionFailure = this.getResponseProperty("RisksSubscriptionFailure");
}
}

View File

@ -0,0 +1,44 @@
import { map, Observable } from "rxjs";
import {
ActiveUserState,
BILLING_BANNERS_DISK,
KeyDefinition,
StateProvider,
} from "../../platform/state";
import { BillingBannerServiceAbstraction } from "../abstractions/billing-banner.service.abstraction";
const PAYMENT_METHOD_BANNERS_KEY = KeyDefinition.record<boolean>(
BILLING_BANNERS_DISK,
"paymentMethodBanners",
{
deserializer: (b) => b,
},
);
export class BillingBannerService implements BillingBannerServiceAbstraction {
private paymentMethodBannerStates: ActiveUserState<Record<string, boolean>>;
paymentMethodBannerStates$: Observable<{ organizationId: string; visible: boolean }[]>;
constructor(private stateProvider: StateProvider) {
this.paymentMethodBannerStates = this.stateProvider.getActive(PAYMENT_METHOD_BANNERS_KEY);
this.paymentMethodBannerStates$ = this.paymentMethodBannerStates.state$.pipe(
map((billingBannerStates) =>
!billingBannerStates
? []
: Object.entries(billingBannerStates).map(([organizationId, visible]) => ({
organizationId,
visible,
})),
),
);
}
async setPaymentMethodBannerState(organizationId: string, visibility: boolean): Promise<void> {
await this.paymentMethodBannerStates.update((states) => {
states ??= {};
states[organizationId] = visibility;
return states;
});
}
}

View File

@ -20,3 +20,5 @@ import { StateDefinition } from "./state-definition";
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");