1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-8161] Payment optional trial MVP (#10872)

* Initial comment

* Add changes for the create org with payment method

* Add the secrets manager trail flow

* Add the banners

* Add changes for the Disabled Org

* Add banner to payment method page

* Refactoring changes

* Resolve the bug on tha payment method

* Resolve lint error

* Resolve Pr comments

* resolve the lint issue

* Resolve the lint wrong file issue

* Rename object properly

* Resolve pr comments from sm team

* Resolve the pr comments from sm team

* Fix the failing test

* Resolve some issue with vault

* Resolve the comments from sm team

* Resolve some pr comments from vault team

* Resolve pr comments from auth team

* Exported ValidOrgParams enum

* Removed unnecessary interpolation

* Corrected bit-banner id for trial

* Resolve pr comments from auth team

* Resolve pr comments from auth team

* Removed unnecessary method

* Made OrganizationCreateRequest a subtype of OrganizationNoPaymentMethodCreateRequest

* Resolve review changes from sm

* Resolve review changes from dm

* Resolve the pr comments from billing

* move the free-trial to core

* Move free-trial change to right file

* Revert changes on the free trial  page

* Resolve the comment on protected trial page

* Resolve the comment on protected trial page

* Revert the next async change

* resolve pr comment fro vault team

* resolve the default message comments

* remove unused method

* resolve email sending issue

* Fix the pop issue on payment method

* Fix some console errors

* Fix the pop refresh page

* move the trial services to billing folder

* resolve pr comments

* Resolve the import issues

* Move the observable up

* Resolve blank payment method for trialing org

* Changes to  disable icon is removed onsubmit

* Remove unused references

* add a missing a period at the end of it

* resolve the reload issue

* Resolve the disable icon issue

* Fix the admin access bug

* Resolve the lint issue

* Fix the message incorrect format

* Formatting fixed

* Resolve the access issue of other users role
This commit is contained in:
cyprain-okeke
2024-11-11 17:05:37 +01:00
committed by GitHub
parent 888b9e346c
commit f593269133
39 changed files with 971 additions and 81 deletions

View File

@@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit {
protected formBuilder: UntypedFormBuilder, protected formBuilder: UntypedFormBuilder,
protected i18nService: I18nService, protected i18nService: I18nService,
protected organizationBillingService: OrganizationBillingService, protected organizationBillingService: OrganizationBillingService,
private router: Router, protected router: Router,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {

View File

@@ -22,12 +22,29 @@
bitButton bitButton
buttonType="primary" buttonType="primary"
[disabled]="formGroup.get('name').invalid" [disabled]="formGroup.get('name').invalid"
[loading]="createOrganizationLoading"
(click)="createOrganizationOnTrial()"
*ngIf="enableTrialPayment$ | async"
>
{{ "startTrial" | i18n }}
</button>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="formGroup.get('name').invalid"
[loading]="createOrganizationLoading"
cdkStepperNext cdkStepperNext
*ngIf="!(enableTrialPayment$ | async)"
> >
{{ "next" | i18n }} {{ "next" | i18n }}
</button> </button>
</app-vertical-step> </app-vertical-step>
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel"> <app-vertical-step
label="{{ 'billing' | i18n | titlecase }}"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step <app-trial-billing-step
*ngIf="stepper.selectedIndex === 2" *ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{ [organizationInfo]="{

View File

@@ -1,6 +1,14 @@
import { Component, Input, ViewChild } from "@angular/core"; import { Component, Input, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { import {
OrganizationCreatedEvent, OrganizationCreatedEvent,
@@ -9,18 +17,64 @@ import {
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component"; } from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component"; import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component"; import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
import { ValidOrgParams } from "../trial-initiation.component";
const trialFlowOrgs = [
ValidOrgParams.teams,
ValidOrgParams.teamsStarter,
ValidOrgParams.enterprise,
ValidOrgParams.families,
];
@Component({ @Component({
selector: "app-secrets-manager-trial-paid-stepper", selector: "app-secrets-manager-trial-paid-stepper",
templateUrl: "secrets-manager-trial-paid-stepper.component.html", templateUrl: "secrets-manager-trial-paid-stepper.component.html",
}) })
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent { export class SecretsManagerTrialPaidStepperComponent
extends SecretsManagerTrialFreeStepperComponent
implements OnInit
{
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@Input() organizationTypeQueryParameter: string; @Input() organizationTypeQueryParameter: string;
plan: PlanType;
createOrganizationLoading = false;
billingSubLabel = this.i18nService.t("billingTrialSubLabel"); billingSubLabel = this.i18nService.t("billingTrialSubLabel");
organizationId: string; organizationId: string;
private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor(
private route: ActivatedRoute,
private configService: ConfigService,
protected formBuilder: UntypedFormBuilder,
protected i18nService: I18nService,
protected organizationBillingService: OrganizationBillingService,
protected router: Router,
) {
super(formBuilder, i18nService, organizationBillingService, router);
}
async ngOnInit(): Promise<void> {
this.referenceEventRequest = new ReferenceEventRequest();
this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website";
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
if (trialFlowOrgs.includes(qParams.org)) {
if (qParams.org === ValidOrgParams.teamsStarter) {
this.plan = PlanType.TeamsStarter;
} else if (qParams.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
} else if (qParams.org === ValidOrgParams.enterprise) {
this.plan = PlanType.EnterpriseAnnually;
}
}
});
}
organizationCreated(event: OrganizationCreatedEvent) { organizationCreated(event: OrganizationCreatedEvent) {
this.organizationId = event.organizationId; this.organizationId = event.organizationId;
this.billingSubLabel = event.planDescription; this.billingSubLabel = event.planDescription;
@@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
this.verticalStepper.previous(); this.verticalStepper.previous();
} }
async createOrganizationOnTrial(): Promise<void> {
this.createOrganizationLoading = true;
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization: {
name: this.formGroup.get("name").value,
billingEmail: this.formGroup.get("email").value,
initiationPath: "Secrets Manager trial from marketing website",
},
plan: {
type: this.plan,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
passwordManagerSeats: 1,
secretsManagerSeats: 1,
},
});
this.organizationId = response?.id;
this.subLabels.organizationInfo = response?.name;
this.createOrganizationLoading = false;
this.verticalStepper.next();
}
get createAccountLabel() { get createAccountLabel() {
const organizationType = const organizationType =
this.productType === ProductTierType.TeamsStarter this.productType === ProductTierType.TeamsStarter

View File

@@ -91,12 +91,17 @@
bitButton bitButton
buttonType="primary" buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').invalid" [disabled]="orgInfoFormGroup.get('name').invalid"
cdkStepperNext [loading]="loading"
(click)="createOrganizationOnTrial()"
> >
{{ "next" | i18n }} {{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button> </button>
</app-vertical-step> </app-vertical-step>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel"> <app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step <app-trial-billing-step
*ngIf="stepper.selectedIndex === 2" *ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{ [organizationInfo]="{

View File

@@ -13,7 +13,9 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@@ -39,6 +41,8 @@ describe("TrialInitiationComponent", () => {
let policyServiceMock: MockProxy<PolicyService>; let policyServiceMock: MockProxy<PolicyService>;
let routerServiceMock: MockProxy<RouterService>; let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>; let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
let configServiceMock: MockProxy<ConfigService>;
beforeEach(() => { beforeEach(() => {
// only define services directly that we want to mock return values in this component // only define services directly that we want to mock return values in this component
@@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => {
policyServiceMock = mock<PolicyService>(); policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>(); routerServiceMock = mock<RouterService>();
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>(); acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
organizationBillingServiceMock = mock<OrganizationBillingService>();
configServiceMock = mock<ConfigService>();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => {
provide: AcceptOrganizationInviteService, provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock, useValue: acceptOrgInviteServiceMock,
}, },
{
provide: OrganizationBillingService,
useValue: organizationBillingServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
], ],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component) schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
}).compileComponents(); }).compileComponents();

View File

@@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
OrganizationInformation,
PlanInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite";
import { RouterService } from "./../../core/router.service"; import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
enum ValidOrgParams { export enum ValidOrgParams {
families = "families", families = "families",
enterprise = "enterprise", enterprise = "enterprise",
teams = "teams", teams = "teams",
@@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
productTier: ProductTierType; productTier: ProductTierType;
accountCreateOnly = true; accountCreateOnly = true;
useTrialStepper = false; useTrialStepper = false;
loading = false;
policies: Policy[]; policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions; enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [ trialFlowOrgs: string[] = [
@@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
} }
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
private i18nService: I18nService, private i18nService: I18nService,
private routerService: RouterService, private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService, private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationBillingService: OrganizationBillingService,
private configService: ConfigService,
) {} ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
} }
} }
async createOrganizationOnTrial() {
this.loading = true;
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.get("name").value,
billingEmail: this.orgInfoFormGroup.get("email").value,
initiationPath: "Password Manager trial from marketing website",
};
const plan: PlanInformation = {
type: this.plan,
passwordManagerSeats: 1,
};
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization,
plan,
});
this.orgId = response?.id;
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
this.loading = false;
this.verticalStepper.next();
}
createdAccount(email: string) { createdAccount(email: string) {
this.email = email; this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email); this.orgInfoFormGroup.get("email")?.setValue(email);

View File

@@ -345,16 +345,22 @@
<a></a> <a></a>
</p> </p>
<app-payment <app-payment
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && !deprecateStripeSourcesAPI" *ngIf="
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
!deprecateStripeSourcesAPI
"
[hideCredit]="true" [hideCredit]="true"
></app-payment> ></app-payment>
<app-payment-v2 <app-payment-v2
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && deprecateStripeSourcesAPI" *ngIf="
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
deprecateStripeSourcesAPI
"
[showAccountCredit]="false" [showAccountCredit]="false"
> >
</app-payment-v2> </app-payment-v2>
<app-tax-info <app-tax-info
*ngIf="showPayment || upgradeRequiresPaymentMethod" *ngIf="showPayment || upgradeRequiresPaymentMethod || isPaymentSourceEmpty()"
(onCountryChanged)="changedCountry()" (onCountryChanged)="changedCountry()"
></app-tax-info> ></app-tax-info>
<div id="price" class="tw-mt-4"> <div id="price" class="tw-mt-4">

View File

@@ -282,6 +282,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
: this.discountPercentageFromSub + this.discountPercentage; : this.discountPercentageFromSub + this.discountPercentage;
} }
isPaymentSourceEmpty() {
return this.deprecateStripeSourcesAPI
? this.paymentSource === null || this.paymentSource === undefined
: this.billing?.paymentSource === null || this.billing?.paymentSource === undefined;
}
isSecretsManagerTrial(): boolean { isSecretsManagerTrial(): boolean {
return ( return (
this.sub?.subscription?.items?.some((item) => this.sub?.subscription?.items?.some((item) =>
@@ -723,7 +729,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
// Secrets Manager // Secrets Manager
this.buildSecretsManagerRequest(request); this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod || this.showPayment) { if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
if (this.deprecateStripeSourcesAPI) { if (this.deprecateStripeSourcesAPI) {
const tokenizedPaymentSource = await this.paymentV2Component.tokenize(); const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();

View File

@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
import { UserVerificationModule } from "../../auth/shared/components/user-verification"; import { UserVerificationModule } from "../../auth/shared/components/user-verification";
import { LooseComponentsModule } from "../../shared"; import { LooseComponentsModule } from "../../shared";
import { BillingSharedModule } from "../shared"; import { BillingSharedModule } from "../shared";
@@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
BillingSharedModule, BillingSharedModule,
OrganizationPlansComponent, OrganizationPlansComponent,
LooseComponentsModule, LooseComponentsModule,
BannerModule,
], ],
declarations: [ declarations: [
AdjustSubscription, AdjustSubscription,

View File

@@ -1,3 +1,22 @@
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
icon="bwi-billing"
[showClose]="false"
*ngIf="freeTrialData?.shownBanner"
>
{{ freeTrialData.message }}
<a
bitLink
linkType="contrast"
(click)="changePayment()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "routeToPaymentMethodTrigger" | i18n }}
</a>
</bit-banner>
<app-header></app-header> <app-header></app-header>
<bit-container> <bit-container>
<ng-container *ngIf="loading"> <ng-container *ngIf="loading">

View File

@@ -1,17 +1,25 @@
import { Component, ViewChild } from "@angular/core"; import { Location } from "@angular/common";
import { Component, OnDestroy, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { from, lastValueFrom, switchMap } from "rxjs"; import { from, lastValueFrom, switchMap } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { FreeTrial } from "../../../core/types/free-trial";
import { TrialFlowService } from "../../services/trial-flow.service";
import { TaxInfoComponent } from "../../shared"; import { TaxInfoComponent } from "../../shared";
import { import {
AddCreditDialogResult, AddCreditDialogResult,
@@ -25,26 +33,36 @@ import {
@Component({ @Component({
templateUrl: "./organization-payment-method.component.html", templateUrl: "./organization-payment-method.component.html",
}) })
export class OrganizationPaymentMethodComponent { export class OrganizationPaymentMethodComponent implements OnDestroy {
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
organizationId: string; organizationId: string;
isUnpaid = false;
accountCredit: number; accountCredit: number;
paymentSource?: PaymentSourceResponse; paymentSource?: PaymentSourceResponse;
subscriptionStatus?: string; subscriptionStatus?: string;
protected freeTrialData: FreeTrial;
organization: Organization;
organizationSubscriptionResponse: OrganizationSubscriptionResponse;
loading = true; loading = true;
protected readonly Math = Math; protected readonly Math = Math;
launchPaymentModalAutomatically = false;
constructor( constructor(
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private billingApiService: BillingApiServiceAbstraction, private billingApiService: BillingApiServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogService, private dialogService: DialogService,
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private router: Router, private router: Router,
private toastService: ToastService, private toastService: ToastService,
private location: Location,
private trialFlowService: TrialFlowService,
private organizationService: OrganizationService,
protected syncService: SyncService,
) { ) {
this.activatedRoute.params this.activatedRoute.params
.pipe( .pipe(
@@ -59,6 +77,23 @@ export class OrganizationPaymentMethodComponent {
}), }),
) )
.subscribe(); .subscribe();
const state = this.router.getCurrentNavigation()?.extras?.state;
// incase the above state is undefined or null we use redundantState
const redundantState: any = location.getState();
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
} else if (
redundantState &&
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
) {
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
} else {
this.launchPaymentModalAutomatically = false;
}
}
ngOnDestroy(): void {
this.launchPaymentModalAutomatically = false;
} }
protected addAccountCredit = async (): Promise<void> => { protected addAccountCredit = async (): Promise<void> => {
@@ -82,6 +117,34 @@ export class OrganizationPaymentMethodComponent {
this.accountCredit = accountCredit; this.accountCredit = accountCredit;
this.paymentSource = paymentSource; this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus; this.subscriptionStatus = subscriptionStatus;
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const organizationPromise = this.organizationService.get(this.organizationId);
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
organizationSubscriptionPromise,
organizationPromise,
]);
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.organizationSubscriptionResponse,
paymentSource,
);
}
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
this.loading = false; this.loading = false;
}; };
@@ -100,6 +163,24 @@ export class OrganizationPaymentMethodComponent {
} }
}; };
changePayment = async () => {
const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, {
data: {
initialPaymentMethod: this.paymentSource?.type,
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogV2ResultType.Submitted) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
await this.syncService.fullSync(true);
}
this.launchPaymentModalAutomatically = false;
await this.load();
}
};
protected updateTaxInformation = async (): Promise<void> => { protected updateTaxInformation = async (): Promise<void> => {
this.taxInfoComponent.taxFormGroup.updateValueAndValidity(); this.taxInfoComponent.taxFormGroup.updateValueAndValidity();
this.taxInfoComponent.taxFormGroup.markAllAsTouched(); this.taxInfoComponent.taxFormGroup.markAllAsTouched();

View File

@@ -0,0 +1,100 @@
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import { FreeTrial } from "../../core/types/free-trial";
@Injectable({ providedIn: "root" })
export class TrialFlowService {
constructor(
private i18nService: I18nService,
protected dialogService: DialogService,
private router: Router,
protected billingApiService: BillingApiServiceAbstraction,
) {}
checkForOrgsWithUpcomingPaymentIssues(
organization: Organization,
organizationSubscription: OrganizationSubscriptionResponse,
paymentSource: BillingSourceResponse | PaymentSourceResponse,
): FreeTrial {
const trialEndDate = organizationSubscription?.subscription?.trialEndDate;
const displayBanner =
!paymentSource &&
organization?.isOwner &&
organizationSubscription?.subscription?.status === "trialing";
const trialRemainingDays = trialEndDate ? this.calculateTrialRemainingDays(trialEndDate) : 0;
const freeTrialMessage = this.getFreeTrialMessage(trialRemainingDays);
return {
remainingDays: trialRemainingDays,
message: freeTrialMessage,
shownBanner: displayBanner,
organizationId: organization.id,
organizationName: organization.name,
};
}
calculateTrialRemainingDays(trialEndDate: string): number | undefined {
const today = new Date();
const trialEnd = new Date(trialEndDate);
const timeDifference = trialEnd.getTime() - today.getTime();
return Math.ceil(timeDifference / (1000 * 60 * 60 * 24));
}
getFreeTrialMessage(trialRemainingDays: number): string {
if (trialRemainingDays >= 2) {
return this.i18nService.t("freeTrialEndPrompt", trialRemainingDays);
} else if (trialRemainingDays === 1) {
return this.i18nService.t("freeTrialEndPromptForOneDayNoOrgName");
} else {
return this.i18nService.t("freeTrialEndingSoonWithoutOrgName");
}
}
async handleUnpaidSubscriptionDialog(
org: Organization,
organizationBillingMetadata: OrganizationBillingMetadataResponse,
): Promise<void> {
if (organizationBillingMetadata.isSubscriptionUnpaid) {
const confirmed = await this.promptForPaymentNavigation(org);
if (confirmed) {
await this.navigateToPaymentMethod(org?.id);
}
}
}
private async promptForPaymentNavigation(org: Organization): Promise<boolean> {
if (!org?.isOwner) {
await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
content: { key: "suspendedUserOrgMessage" },
type: "danger",
acceptButtonText: this.i18nService.t("close"),
cancelButtonText: null,
});
return false;
}
return await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
content: { key: "suspendedOwnerOrgMessage" },
type: "danger",
acceptButtonText: this.i18nService.t("continue"),
cancelButtonText: this.i18nService.t("close"),
});
}
private async navigateToPaymentMethod(orgId: string) {
await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], {
state: { launchPaymentModalAutomatically: true },
});
}
}

View File

@@ -74,6 +74,7 @@ export class AdjustPaymentDialogComponent {
} }
}); });
await response; await response;
await new Promise((resolve) => setTimeout(resolve, 10000));
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: null,

View File

@@ -1,5 +1,7 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BannerModule } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module"; import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared"; import { SharedModule } from "../../shared";
@@ -27,6 +29,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
PaymentComponent, PaymentComponent,
TaxInfoComponent, TaxInfoComponent,
HeaderModule, HeaderModule,
BannerModule,
PaymentV2Component, PaymentV2Component,
VerifyBankAccountComponent, VerifyBankAccountComponent,
], ],

View File

@@ -1,3 +1,23 @@
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
icon="bwi-billing"
[showClose]="false"
*ngIf="freeTrialData?.shownBanner"
>
{{ freeTrialData?.message }}
<a
bitLink
linkType="contrast"
(click)="changePayment()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "routeToPaymentMethodTrigger" | i18n }}
</a>
</bit-banner>
<app-header *ngIf="organizationId"> <app-header *ngIf="organizationId">
<button <button
type="button" type="button"
@@ -77,7 +97,13 @@
{{ paymentSource.description }} {{ paymentSource.description }}
</p> </p>
</ng-container> </ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment"> <button
type="button"
bitButton
buttonType="secondary"
class="payment_trigger_button"
[bitAction]="changePayment"
>
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }} {{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button> </button>
<p *ngIf="isUnpaid" bitTypography="body1"> <p *ngIf="isUnpaid" bitTypography="body1">

View File

@@ -1,10 +1,13 @@
import { Component, OnInit, ViewChild } from "@angular/core"; import { Location } from "@angular/common";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms"; import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
@@ -13,8 +16,12 @@ import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { FreeTrial } from "../../core/types/free-trial";
import { TrialFlowService } from "../services/trial-flow.service";
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
import { import {
AdjustPaymentDialogResult, AdjustPaymentDialogResult,
@@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component";
templateUrl: "payment-method.component.html", templateUrl: "payment-method.component.html",
}) })
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class PaymentMethodComponent implements OnInit { export class PaymentMethodComponent implements OnInit, OnDestroy {
@ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent; @ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent;
loading = false; loading = false;
@@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit {
paymentMethodType = PaymentMethodType; paymentMethodType = PaymentMethodType;
organizationId: string; organizationId: string;
isUnpaid = false; isUnpaid = false;
organization: Organization;
verifyBankForm = this.formBuilder.group({ verifyBankForm = this.formBuilder.group({
amount1: new FormControl<number>(null, [ amount1: new FormControl<number>(null, [
@@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit {
}); });
taxForm = this.formBuilder.group({}); taxForm = this.formBuilder.group({});
launchPaymentModalAutomatically = false;
protected freeTrialData: FreeTrial;
constructor( constructor(
protected apiService: ApiService, protected apiService: ApiService,
@@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit {
protected i18nService: I18nService, protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService, protected platformUtilsService: PlatformUtilsService,
private router: Router, private router: Router,
private location: Location,
private logService: LogService, private logService: LogService,
private route: ActivatedRoute, private route: ActivatedRoute,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private dialogService: DialogService, private dialogService: DialogService,
private toastService: ToastService, private toastService: ToastService,
) {} private trialFlowService: TrialFlowService,
private organizationService: OrganizationService,
protected syncService: SyncService,
) {
const state = this.router.getCurrentNavigation()?.extras?.state;
// incase the above state is undefined or null we use redundantState
const redundantState: any = location.getState();
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
} else if (
redundantState &&
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
) {
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
} else {
this.launchPaymentModalAutomatically = false;
}
}
async ngOnInit() { async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
@@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit {
return; return;
} }
this.loading = true; this.loading = true;
if (this.forOrganization) { if (this.forOrganization) {
const billingPromise = this.organizationApiService.getBilling(this.organizationId); const billingPromise = this.organizationApiService.getBilling(this.organizationId);
const organizationSubscriptionPromise = this.organizationApiService.getSubscription( const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId, this.organizationId,
); );
const organizationPromise = this.organizationService.get(this.organizationId);
[this.billing, this.org] = await Promise.all([ [this.billing, this.org, this.organization] = await Promise.all([
billingPromise, billingPromise,
organizationSubscriptionPromise, organizationSubscriptionPromise,
organizationPromise,
]); ]);
this.determineOrgsWithUpcomingPaymentIssues();
} else { } else {
const billingPromise = this.apiService.getUserBillingPayment(); const billingPromise = this.apiService.getUserBillingPayment();
const subPromise = this.apiService.getUserSubscription(); const subPromise = this.apiService.getUserSubscription();
[this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]);
} }
this.isUnpaid = this.subscription?.status === "unpaid" ?? false; this.isUnpaid = this.subscription?.status === "unpaid" ?? false;
this.loading = false; this.loading = false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
}; };
addCredit = async () => { addCredit = async () => {
@@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit {
}); });
const result = await lastValueFrom(dialogRef.closed); const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResult.Adjusted) { if (result === AdjustPaymentDialogResult.Adjusted) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
await this.syncService.fullSync(true);
}
this.launchPaymentModalAutomatically = false;
await this.load(); await this.load();
} }
}; };
@@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit {
}); });
}; };
determineOrgsWithUpcomingPaymentIssues() {
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.org,
this.billing?.paymentSource,
);
}
get isCreditBalance() { get isCreditBalance() {
return this.billing == null || this.billing.balance <= 0; return this.billing == null || this.billing.balance <= 0;
} }
@@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit {
get subscription() { get subscription() {
return this.sub?.subscription ?? this.org?.subscription ?? null; return this.sub?.subscription ?? this.org?.subscription ?? null;
} }
ngOnDestroy(): void {
this.launchPaymentModalAutomatically = false;
}
} }

View File

@@ -0,0 +1,7 @@
export type FreeTrial = {
remainingDays: number;
message: string;
shownBanner: boolean;
organizationId: string;
organizationName: string;
};

View File

@@ -22,6 +22,7 @@
[route]="['../', org.id]" [route]="['../', org.id]"
(mainContentClicked)="toggle()" (mainContentClicked)="toggle()"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
(click)="handleUnpaidSubscription(org)"
> >
<i <i
slot="end" slot="end"

View File

@@ -6,7 +6,10 @@ import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import type { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import type { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { NavigationModule } from "@bitwarden/components"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { DialogService, NavigationModule } from "@bitwarden/components";
import { TrialFlowService } from "./../../billing/services/trial-flow.service";
@Component({ @Component({
selector: "org-switcher", selector: "org-switcher",
@@ -52,7 +55,10 @@ export class OrgSwitcherComponent {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
protected dialogService: DialogService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private trialFlowService: TrialFlowService,
protected billingApiService: BillingApiServiceAbstraction,
) {} ) {}
protected toggle(event?: MouseEvent) { protected toggle(event?: MouseEvent) {
@@ -60,4 +66,9 @@ export class OrgSwitcherComponent {
this.open = !this.open; this.open = !this.open;
this.openChange.emit(this.open); this.openChange.emit(this.open);
} }
async handleUnpaidSubscription(org: Organization) {
const metaData = await this.billingApiService.getOrganizationBillingMetadata(org.id);
await this.trialFlowService.handleUnpaidSubscriptionDialog(org, metaData);
}
} }

View File

@@ -1,3 +1,23 @@
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
icon="bwi-billing"
[showClose]="false"
*ngFor="let organization of organizationsPaymentStatus; trackBy: trackBy; index as i"
>
{{ freeTrialMessage(organization) }}
<a
bitLink
linkType="contrast"
(click)="navigateToPaymentMethod(organization?.organizationId)"
rel="noreferrer noopener"
class="tw-cursor-pointer"
>
{{ "routeToPaymentMethodTrigger" | i18n }}
</a>
</bit-banner>
<bit-banner <bit-banner
id="update-browser-banner" id="update-browser-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6" class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"

View File

@@ -1,9 +1,12 @@
import { Component, OnInit } from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BannerModule } from "@bitwarden/components"; import { BannerModule } from "@bitwarden/components";
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component"; import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
import { FreeTrial } from "../../../core/types/free-trial";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service"; import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
@@ -19,8 +22,13 @@ export class VaultBannersComponent implements OnInit {
visibleBanners: VisibleVaultBanner[] = []; visibleBanners: VisibleVaultBanner[] = [];
premiumBannerVisible$: Observable<boolean>; premiumBannerVisible$: Observable<boolean>;
VisibleVaultBanner = VisibleVaultBanner; VisibleVaultBanner = VisibleVaultBanner;
@Input() organizationsPaymentStatus: FreeTrial[] = [];
constructor(private vaultBannerService: VaultBannersService) { constructor(
private vaultBannerService: VaultBannersService,
private router: Router,
private i18nService: I18nService,
) {
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
} }
@@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit {
await this.determineVisibleBanners(); await this.determineVisibleBanners();
} }
async navigateToPaymentMethod(organizationId: string): Promise<void> {
const navigationExtras = {
state: { launchPaymentModalAutomatically: true },
};
await this.router.navigate(
["organizations", organizationId, "billing", "payment-method"],
navigationExtras,
);
}
/** Determine which banners should be present */ /** Determine which banners should be present */
private async determineVisibleBanners(): Promise<void> { private async determineVisibleBanners(): Promise<void> {
const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner();
@@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit {
showLowKdf ? VisibleVaultBanner.KDFSettings : null, showLowKdf ? VisibleVaultBanner.KDFSettings : null,
].filter(Boolean); // remove all falsy values, i.e. null ].filter(Boolean); // remove all falsy values, i.e. null
} }
freeTrialMessage(organization: FreeTrial) {
if (organization.remainingDays >= 2) {
return this.i18nService.t(
"freeTrialEndPromptAboveTwoDays",
organization.organizationName,
organization.remainingDays.toString(),
);
} else if (organization.remainingDays === 1) {
return this.i18nService.t("freeTrialEndPromptForOneDay", organization.organizationName);
} else {
return this.i18nService.t("freeTrialEndPromptForLessThanADay", organization.organizationName);
}
}
trackBy(index: number) {
return index;
}
} }

View File

@@ -1,12 +1,16 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs"; import { firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService } from "@bitwarden/components";
import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service";
import { import {
@@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
isLoaded = false; isLoaded = false;
protected destroy$: Subject<void> = new Subject<void>(); protected destroy$: Subject<void> = new Subject<void>();
private router = inject(Router);
get filtersList() { get filtersList() {
return this.filters ? Object.values(this.filters) : []; return this.filters ? Object.values(this.filters) : [];
} }
@@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected policyService: PolicyService, protected policyService: PolicyService,
protected i18nService: I18nService, protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService, protected platformUtilsService: PlatformUtilsService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
) {} ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
null, null,
this.i18nService.t("disabledOrganizationFilterError"), this.i18nService.t("disabledOrganizationFilterError"),
); );
const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id);
if (metadata.isSubscriptionUnpaid) {
const confirmed = await this.promptForPaymentNavigation(orgNode.node);
if (confirmed) {
await this.navigateToPaymentMethod(orgNode.node.id);
}
}
return; return;
} }
const filter = this.activeFilter; const filter = this.activeFilter;
@@ -123,6 +136,32 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
await this.vaultFilterService.expandOrgFilter(); await this.vaultFilterService.expandOrgFilter();
}; };
private async promptForPaymentNavigation(org: Organization): Promise<boolean> {
if (!org?.isOwner) {
await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
content: { key: "suspendedUserOrgMessage" },
type: "danger",
acceptButtonText: this.i18nService.t("close"),
cancelButtonText: null,
});
return false;
}
return await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
content: { key: "suspendedOwnerOrgMessage" },
type: "danger",
acceptButtonText: this.i18nService.t("continue"),
cancelButtonText: this.i18nService.t("close"),
});
}
private async navigateToPaymentMethod(orgId: string) {
await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], {
state: { launchPaymentModalAutomatically: true },
});
}
applyTypeFilter = async (filterNode: TreeNode<CipherTypeFilter>): Promise<void> => { applyTypeFilter = async (filterNode: TreeNode<CipherTypeFilter>): Promise<void> => {
const filter = this.activeFilter; const filter = this.activeFilter;
filter.resetFilter(); filter.resetFilter();

View File

@@ -1,4 +1,4 @@
<app-vault-banners></app-vault-banners> <app-vault-banners [organizationsPaymentStatus]="organizationsPaymentStatus"></app-vault-banners>
<app-vault-header <app-vault-header
[filter]="filter" [filter]="filter"

View File

@@ -13,6 +13,7 @@ import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
firstValueFrom, firstValueFrom,
from,
lastValueFrom, lastValueFrom,
Observable, Observable,
Subject, Subject,
@@ -41,10 +42,12 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@@ -72,6 +75,8 @@ import {
PasswordRepromptService, PasswordRepromptService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { TrialFlowService } from "../../billing/services/trial-flow.service";
import { FreeTrial } from "../../core/types/free-trial";
import { SharedModule } from "../../shared/shared.module"; import { SharedModule } from "../../shared/shared.module";
import { AssignCollectionsWebComponent } from "../components/assign-collections"; import { AssignCollectionsWebComponent } from "../components/assign-collections";
import { import {
@@ -174,12 +179,28 @@ export class VaultComponent implements OnInit, OnDestroy {
protected canCreateCollections = false; protected canCreateCollections = false;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
private activeUserId: UserId; private activeUserId: UserId;
protected organizationsPaymentStatus: FreeTrial[] = [];
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private extensionRefreshEnabled: boolean; private extensionRefreshEnabled: boolean;
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined; private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
filter((organizations) => organizations.length === 1),
switchMap(([organization]) =>
from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe(
switchMap((organizationMetaData) =>
from(
this.trialFlowService.handleUnpaidSubscriptionDialog(
organization,
organizationMetaData,
),
),
),
),
),
);
constructor( constructor(
private syncService: SyncService, private syncService: SyncService,
@@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy {
private toastService: ToastService, private toastService: ToastService,
private accountService: AccountService, private accountService: AccountService,
private cipherFormConfigService: DefaultCipherFormConfigService, private cipherFormConfigService: DefaultCipherFormConfigService,
private organizationApiService: OrganizationApiServiceAbstraction,
protected billingApiService: BillingApiServiceAbstraction,
private trialFlowService: TrialFlowService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy {
if (filter.collectionId === undefined || filter.collectionId === Unassigned) { if (filter.collectionId === undefined || filter.collectionId === Unassigned) {
return []; return [];
} }
let collectionsToReturn = []; let collectionsToReturn = [];
if (filter.organizationId !== undefined && filter.collectionId === All) { if (filter.organizationId !== undefined && filter.collectionId === All) {
collectionsToReturn = collections collectionsToReturn = collections
@@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy {
filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled), filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled),
switchMap(async (params) => { switchMap(async (params) => {
const cipherId = getCipherIdFromParams(params); const cipherId = getCipherIdFromParams(params);
if (cipherId) { if (cipherId) {
if (await this.cipherService.get(cipherId)) { if (await this.cipherService.get(cipherId)) {
let action = params.action; let action = params.action;
@@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy {
) )
.subscribe(); .subscribe();
this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
const organizationsPaymentStatus$ = this.organizationService.organizations$.pipe(
switchMap((allOrganizations) => {
return combineLatest(
allOrganizations
.filter((org) => org.isOwner)
.map((org) =>
combineLatest([
this.organizationApiService.getSubscription(org.id),
this.organizationApiService.getBilling(org.id),
]).pipe(
map(([subscription, billing]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
org,
subscription,
billing?.paymentSource,
);
}),
),
),
);
}),
map((results) => results.filter((result) => result.shownBanner)),
);
firstSetup$ firstSetup$
.pipe( .pipe(
switchMap(() => this.refresh$), switchMap(() => this.refresh$),
@@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy {
ciphers$, ciphers$,
collections$, collections$,
selectedCollection$, selectedCollection$,
organizationsPaymentStatus$,
]), ]),
), ),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy {
ciphers, ciphers,
collections, collections,
selectedCollection, selectedCollection,
organizationsPaymentStatus,
]) => { ]) => {
this.filter = filter; this.filter = filter;
this.canAccessPremium = canAccessPremium; this.canAccessPremium = canAccessPremium;
@@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.showBulkMove = filter.type !== "trash"; this.showBulkMove = filter.type !== "trash";
this.isEmpty = collections?.length === 0 && ciphers?.length === 0; this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
this.organizationsPaymentStatus = organizationsPaymentStatus;
this.performingInitialLoad = false; this.performingInitialLoad = false;
this.refreshing = false; this.refreshing = false;
}, },

View File

@@ -3,9 +3,11 @@ import { firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService } from "@bitwarden/components";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component";
import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service";
@@ -38,8 +40,17 @@ export class VaultFilterComponent
protected policyService: PolicyService, protected policyService: PolicyService,
protected i18nService: I18nService, protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService, protected platformUtilsService: PlatformUtilsService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
) { ) {
super(vaultFilterService, policyService, i18nService, platformUtilsService); super(
vaultFilterService,
policyService,
i18nService,
platformUtilsService,
billingApiService,
dialogService,
);
} }
async ngOnInit() { async ngOnInit() {

View File

@@ -1,3 +1,25 @@
<ng-container *ngIf="freeTrial$ | async as freeTrial">
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="premium"
[showClose]="false"
*ngIf="!refreshing && freeTrial.shownBanner"
>
{{ freeTrial.message }}
<a
bitLink
linkType="contrast"
(click)="navigateToPaymentMethod()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "routeToPaymentMethodTrigger" | i18n }}
</a>
</bit-banner>
</ng-container>
<app-org-vault-header <app-org-vault-header
[filter]="filter" [filter]="filter"
[loading]="refreshing" [loading]="refreshing"

View File

@@ -13,8 +13,10 @@ import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
firstValueFrom, firstValueFrom,
from,
lastValueFrom, lastValueFrom,
Observable, Observable,
of,
Subject, Subject,
} from "rxjs"; } from "rxjs";
import { import {
@@ -43,8 +45,10 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@@ -63,7 +67,13 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, NoItemsModule, ToastService } from "@bitwarden/components"; import {
DialogService,
Icons,
NoItemsModule,
ToastService,
BannerModule,
} from "@bitwarden/components";
import { import {
CipherFormConfig, CipherFormConfig,
CipherFormConfigService, CipherFormConfigService,
@@ -73,6 +83,8 @@ import {
import { GroupService, GroupView } from "../../admin-console/organizations/core"; import { GroupService, GroupView } from "../../admin-console/organizations/core";
import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component"; import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component";
import { TrialFlowService } from "../../billing/services/trial-flow.service";
import { FreeTrial } from "../../core/types/free-trial";
import { SharedModule } from "../../shared"; import { SharedModule } from "../../shared";
import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model";
@@ -130,6 +142,7 @@ enum AddAccessStatusType {
VaultFilterModule, VaultFilterModule,
VaultItemsModule, VaultItemsModule,
SharedModule, SharedModule,
BannerModule,
NoItemsModule, NoItemsModule,
], ],
providers: [ providers: [
@@ -166,6 +179,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected isEmpty: boolean; protected isEmpty: boolean;
protected showCollectionAccessRestricted: boolean; protected showCollectionAccessRestricted: boolean;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
protected freeTrial$: Observable<FreeTrial>;
/** /**
* A list of collections that the user can assign items to and edit those items within. * A list of collections that the user can assign items to and edit those items within.
* @protected * @protected
@@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy {
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0); protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private extensionRefreshEnabled: boolean; private extensionRefreshEnabled: boolean;
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined; private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
filter((organizations) => organizations.length === 1),
switchMap(([organization]) =>
from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe(
switchMap((organizationMetaData) =>
from(
this.trialFlowService.handleUnpaidSubscriptionDialog(
organization,
organizationMetaData,
),
),
),
),
),
);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy {
private toastService: ToastService, private toastService: ToastService,
private configService: ConfigService, private configService: ConfigService,
private cipherFormConfigService: CipherFormConfigService, private cipherFormConfigService: CipherFormConfigService,
private organizationApiService: OrganizationApiServiceAbstraction,
private trialFlowService: TrialFlowService,
protected billingApiService: BillingApiServiceAbstraction,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy {
) )
.subscribe(); .subscribe();
this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
this.freeTrial$ = organization$.pipe(
filter((org) => org.isOwner),
switchMap((org) =>
combineLatest([
of(org),
this.organizationApiService.getSubscription(org.id),
this.organizationApiService.getBilling(org.id),
]),
),
map(([org, sub, billing]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
org,
sub,
billing?.paymentSource,
);
}),
);
firstSetup$ firstSetup$
.pipe( .pipe(
switchMap(() => this.refresh$), switchMap(() => this.refresh$),
@@ -596,6 +648,13 @@ export class VaultComponent implements OnInit, OnDestroy {
); );
} }
async navigateToPaymentMethod() {
await this.router.navigate(
["organizations", `${this.organization?.id}`, "billing", "payment-method"],
{ state: { launchPaymentModalAutomatically: true } },
);
}
addAccessToggle(e: AddAccessStatusType) { addAccessToggle(e: AddAccessStatusType) {
this.addAccessStatus$.next(e); this.addAccessStatus$.next(e);
} }

View File

@@ -3837,6 +3837,55 @@
"updateBrowserDesc": { "updateBrowserDesc": {
"message": "You are using an unsupported web browser. The web vault may not function properly." "message": "You are using an unsupported web browser. The web vault may not function properly."
}, },
"freeTrialEndPrompt": {
"message": "Your free trial ends in $COUNT$ days. To maintain your subscription,",
"placeholders": {
"count": {
"content": "$1",
"example": "You must set up 2FA on your user account before you can join this organization."
}
}
},
"freeTrialEndPromptAboveTwoDays": {
"message": "$ORGANIZATION$, your free trial ends in $COUNT$ days. To maintain your subscription,",
"placeholders": {
"count": {
"content": "$2",
"example": "organization name"
},
"organization": {
"content": "$1",
"example": "remaining days"
}
}
},
"freeTrialEndPromptForOneDay": {
"message": "$ORGANIZATION$, your free trial ends tomorrow. To maintain your subscription,",
"placeholders": {
"organization": {
"content": "$1",
"example": "organization name"
}
}
},
"freeTrialEndPromptForOneDayNoOrgName": {
"message": "Your free trial ends tomorrow. To maintain your subscription,"
},
"freeTrialEndPromptForLessThanADay": {
"message": "$ORGANIZATION$, your free trial ends today. To maintain your subscription,",
"placeholders": {
"organization": {
"content": "$1",
"example": "organization name"
}
}
},
"freeTrialEndingSoonWithoutOrgName": {
"message": "Your free trial ends today. To maintain your subscription,"
},
"routeToPaymentMethodTrigger": {
"message": "add a payment method."
},
"joinOrganization": { "joinOrganization": {
"message": "Join organization" "message": "Join organization"
}, },
@@ -8444,7 +8493,7 @@
}, },
"addAPaymentMethod": { "addAPaymentMethod": {
"message": "add a payment method", "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.'" "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'"
}, },
"organizationInformation": { "organizationInformation": {
"message": "Organization information" "message": "Organization information"
@@ -9631,5 +9680,20 @@
"example": "First 8 Character of a GUID" "example": "First 8 Character of a GUID"
} }
} }
},
"suspendedOrganizationTitle": {
"message": "The $ORGANIZATION$ is suspended",
"placeholders": {
"organization": {
"content": "$1",
"example": "Acme c"
}
}
},
"suspendedUserOrgMessage": {
"message": "Contact your organization owner for assistance."
},
"suspendedOwnerOrgMessage": {
"message": "To regain access to your organization, add a payment method."
} }
} }

View File

@@ -1,3 +1,24 @@
<ng-container *ngIf="freeTrial$ | async as freeTrial">
<bit-banner
id="update-browser-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
icon="bwi-billing"
[showClose]="false"
*ngIf="!loading && freeTrial.shownBanner"
>
{{ freeTrial.message }}
<a
bitLink
linkType="contrast"
class="tw-cursor-pointer"
(click)="navigateToPaymentMethod()"
rel="noreferrer noopener"
>
{{ "routeToPaymentMethodTrigger" | i18n }}
</a>
</bit-banner>
</ng-container>
<app-header [title]="organizationName"> <app-header [title]="organizationName">
<sm-new-menu></sm-new-menu> <sm-new-menu></sm-new-menu>
</app-header> </app-header>

View File

@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { import {
map, map,
Observable, Observable,
@@ -12,14 +12,20 @@ import {
take, take,
share, share,
firstValueFrom, firstValueFrom,
concatMap, of,
filter,
} from "rxjs"; } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service";
import { FreeTrial } from "@bitwarden/web-vault/app/core/types/free-trial";
import { OrganizationCounts } from "../models/view/counts.view"; import { OrganizationCounts } from "../models/view/counts.view";
import { ProjectListView } from "../models/view/project-list.view"; import { ProjectListView } from "../models/view/project-list.view";
@@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy {
protected showOnboarding = false; protected showOnboarding = false;
protected loading = true; protected loading = true;
protected organizationEnabled = false; protected organizationEnabled = false;
protected organization: Organization;
protected i18n: I18nPipe;
protected onboardingTasks$: Observable<SMOnboardingTasks>; protected onboardingTasks$: Observable<SMOnboardingTasks>;
protected view$: Observable<{ protected view$: Observable<{
@@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
tasks: OrganizationTasks; tasks: OrganizationTasks;
counts: OrganizationCounts; counts: OrganizationCounts;
}>; }>;
protected freeTrial$: Observable<FreeTrial>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy {
private i18nService: I18nService, private i18nService: I18nService,
private smOnboardingTasksService: SMOnboardingTasksService, private smOnboardingTasksService: SMOnboardingTasksService,
private logService: LogService, private logService: LogService,
private router: Router,
private organizationApiService: OrganizationApiServiceAbstraction,
private trialFlowService: TrialFlowService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy {
distinctUntilChanged(), distinctUntilChanged(),
); );
orgId$ const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId)));
.pipe(
concatMap(async (orgId) => await this.organizationService.get(orgId)), org$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
takeUntil(this.destroy$), this.organizationId = org.id;
) this.organization = org;
.subscribe((org) => { this.organizationName = org.name;
this.organizationId = org.id; this.userIsAdmin = org.isAdmin;
this.organizationName = org.name; this.loading = true;
this.userIsAdmin = org.isAdmin; this.organizationEnabled = org.enabled;
this.loading = true; });
this.organizationEnabled = org.enabled;
}); this.freeTrial$ = org$.pipe(
filter((org) => org.isOwner),
switchMap((org) =>
combineLatest([
of(org),
this.organizationApiService.getSubscription(org.id),
this.organizationApiService.getBilling(org.id),
]),
),
map(([org, sub, billing]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
org,
sub,
billing?.paymentSource,
);
}),
takeUntil(this.destroy$),
);
const projects$ = combineLatest([ const projects$ = combineLatest([
orgId$, orgId$,
@@ -197,6 +227,15 @@ export class OverviewComponent implements OnInit, OnDestroy {
}); });
} }
async navigateToPaymentMethod() {
await this.router.navigate(
["organizations", `${this.organizationId}`, "billing", "payment-method"],
{
state: { launchPaymentModalAutomatically: true },
},
);
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();

View File

@@ -1,5 +1,7 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BannerModule } from "@bitwarden/components";
import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module"; import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
@@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component";
import { SectionComponent } from "./section.component"; import { SectionComponent } from "./section.component";
@NgModule({ @NgModule({
imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule], imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule],
declarations: [OverviewComponent, SectionComponent], declarations: [OverviewComponent, SectionComponent],
providers: [], providers: [],
}) })

View File

@@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request";
@@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction {
getLicense: (id: string, installationId: string) => Promise<unknown>; getLicense: (id: string, installationId: string) => Promise<unknown>;
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>; getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>; create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
createWithoutPayment: (
request: OrganizationNoPaymentMethodCreateRequest,
) => Promise<OrganizationResponse>;
createLicense: (data: FormData) => Promise<OrganizationResponse>; createLicense: (data: FormData) => Promise<OrganizationResponse>;
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>; save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
updatePayment: (id: string, request: PaymentRequest) => Promise<void>; updatePayment: (id: string, request: PaymentRequest) => Promise<void>;

View File

@@ -1,32 +1,7 @@
import { PaymentMethodType, PlanType } from "../../../billing/enums"; import { PaymentMethodType } from "../../../billing/enums";
import { InitiationPath } from "../../../models/request/reference-event.request"; import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationKeysRequest } from "./organization-keys.request"; export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest {
export class OrganizationCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
paymentMethodType: PaymentMethodType; paymentMethodType: PaymentMethodType;
paymentToken: string; paymentToken: string;
additionalSeats: number;
maxAutoscaleSeats: number;
additionalStorageGb: number;
premiumAccessAddon: boolean;
collectionName: string;
taxIdNumber: string;
billingAddressLine1: string;
billingAddressLine2: string;
billingAddressCity: string;
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
} }

View File

@@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request";
@@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new OrganizationResponse(r); return new OrganizationResponse(r);
} }
async createWithoutPayment(
request: OrganizationNoPaymentMethodCreateRequest,
): Promise<OrganizationResponse> {
const r = await this.apiService.send(
"POST",
"/organizations/create-without-payment",
request,
true,
true,
);
// Forcing a sync will notify organization service that they need to repull
await this.syncService.fullSync(true);
return new OrganizationResponse(r);
}
async createLicense(data: FormData): Promise<OrganizationResponse> { async createLicense(data: FormData): Promise<OrganizationResponse> {
const r = await this.apiService.send( const r = await this.apiService.send(
"POST", "POST",

View File

@@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction {
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>; purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>; startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
purchaseSubscriptionNoPaymentMethod: (
subscription: SubscriptionInformation,
) => Promise<OrganizationResponse>;
} }

View File

@@ -0,0 +1,29 @@
import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { PlanType } from "../../enums";
export class OrganizationNoPaymentMethodCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
additionalSeats: number;
maxAutoscaleSeats: number;
additionalStorageGb: number;
premiumAccessAddon: boolean;
collectionName: string;
taxIdNumber: string;
billingAddressLine1: string;
billingAddressLine2: string;
billingAddressCity: string;
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
isEligibleForSelfHost: boolean; isEligibleForSelfHost: boolean;
isManaged: boolean; isManaged: boolean;
isOnSecretsManagerStandalone: boolean; isOnSecretsManagerStandalone: boolean;
isSubscriptionUnpaid: boolean;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
this.isManaged = this.getResponseProperty("IsManaged"); this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
} }
} }

View File

@@ -17,6 +17,7 @@ import {
SubscriptionInformation, SubscriptionInformation,
} from "../abstractions/organization-billing.service"; } from "../abstractions/organization-billing.service";
import { PlanType } from "../enums"; import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
interface OrganizationKeys { interface OrganizationKeys {
encryptedKey: EncString; encryptedKey: EncString;
@@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
return response; return response;
} }
async purchaseSubscriptionNoPaymentMethod(
subscription: SubscriptionInformation,
): Promise<OrganizationResponse> {
const request = new OrganizationNoPaymentMethodCreateRequest();
const organizationKeys = await this.makeOrganizationKeys();
this.setOrganizationKeys(request, organizationKeys);
this.setOrganizationInformation(request, subscription.organization);
this.setPlanInformation(request, subscription.plan);
const response = await this.organizationApiService.createWithoutPayment(request);
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
return response;
}
private async makeOrganizationKeys(): Promise<OrganizationKeys> { private async makeOrganizationKeys(): Promise<OrganizationKeys> {
const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>(); const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>();
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key); const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key);
@@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
} }
private setOrganizationInformation( private setOrganizationInformation(
request: OrganizationCreateRequest, request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: OrganizationInformation, information: OrganizationInformation,
): void { ): void {
request.name = information.name; request.name = information.name;
@@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
request.initiationPath = information.initiationPath; request.initiationPath = information.initiationPath;
} }
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void { private setOrganizationKeys(
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
keys: OrganizationKeys,
): void {
request.key = keys.encryptedKey.encryptedString; request.key = keys.encryptedKey.encryptedString;
request.keys = new OrganizationKeysRequest( request.keys = new OrganizationKeysRequest(
keys.publicKey, keys.publicKey,
@@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
} }
private setPlanInformation( private setPlanInformation(
request: OrganizationCreateRequest, request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: PlanInformation, information: PlanInformation,
): void { ): void {
request.planType = information.type; request.planType = information.type;

View File

@@ -38,6 +38,7 @@ export enum FeatureFlag {
Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions", Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions",
LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split", LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split",
CriticalApps = "pm-14466-risk-insights-critical-application", CriticalApps = "pm-14466-risk-insights-critical-application",
TrialPaymentOptional = "PM-8163-trial-payment",
} }
export type AllowedFeatureFlagTypes = boolean | number | string; export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -86,6 +87,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE, [FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE, [FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
[FeatureFlag.CriticalApps]: FALSE, [FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>; } satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;