mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
[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:
parent
4475f67bbc
commit
014281cb93
@ -38,7 +38,7 @@ import {
|
||||
],
|
||||
})
|
||||
export class Fido2UseBrowserLinkComponent {
|
||||
showOverlay: boolean = false;
|
||||
showOverlay = false;
|
||||
isOpen = false;
|
||||
overlayPosition: ConnectedPosition[] = [
|
||||
{
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
<app-navbar></app-navbar>
|
||||
<app-payment-method-banners></app-payment-method-banners>
|
||||
<router-outlet></router-outlet>
|
||||
<app-footer></app-footer>
|
||||
|
@ -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,
|
||||
|
@ -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.'"
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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: [
|
||||
|
@ -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>
|
||||
|
@ -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 {}
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export class BillingBannerServiceAbstraction {
|
||||
paymentMethodBannerStates$: Observable<{ organizationId: string; visible: boolean }[]>;
|
||||
setPaymentMethodBannerState: (organizationId: string, visible: boolean) => Promise<void>;
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
44
libs/common/src/billing/services/billing-banner.service.ts
Normal file
44
libs/common/src/billing/services/billing-banner.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
Loading…
Reference in New Issue
Block a user