1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-24 12:06:15 +01: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 971 additions and 81 deletions

View File

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

View File

@ -22,12 +22,29 @@
bitButton
buttonType="primary"
[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
*ngIf="!(enableTrialPayment$ | async)"
>
{{ "next" | i18n }}
</button>
</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
*ngIf="stepper.selectedIndex === 2"
[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 {
OrganizationCreatedEvent,
@ -9,18 +17,64 @@ import {
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-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({
selector: "app-secrets-manager-trial-paid-stepper",
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;
@Input() organizationTypeQueryParameter: string;
plan: PlanType;
createOrganizationLoading = false;
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
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) {
this.organizationId = event.organizationId;
this.billingSubLabel = event.planDescription;
@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
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() {
const organizationType =
this.productType === ProductTierType.TeamsStarter

View File

@ -91,12 +91,17 @@
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').invalid"
cdkStepperNext
[loading]="loading"
(click)="createOrganizationOnTrial()"
>
{{ "next" | i18n }}
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@ -39,6 +41,8 @@ describe("TrialInitiationComponent", () => {
let policyServiceMock: MockProxy<PolicyService>;
let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
let configServiceMock: MockProxy<ConfigService>;
beforeEach(() => {
// only define services directly that we want to mock return values in this component
@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => {
policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>();
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.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => {
provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock,
},
{
provide: OrganizationBillingService,
useValue: organizationBillingServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
}).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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
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 { 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 { 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 { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
enum ValidOrgParams {
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
productTier: ProductTierType;
accountCreateOnly = true;
useTrialStepper = false;
loading = false;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
}
private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor(
private route: ActivatedRoute,
@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
private i18nService: I18nService,
private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationBillingService: OrganizationBillingService,
private configService: ConfigService,
) {}
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) {
this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email);

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
import { UserVerificationModule } from "../../auth/shared/components/user-verification";
import { LooseComponentsModule } from "../../shared";
import { BillingSharedModule } from "../shared";
@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
BillingSharedModule,
OrganizationPlansComponent,
LooseComponentsModule,
BannerModule,
],
declarations: [
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>
<bit-container>
<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 { ActivatedRoute, Router } from "@angular/router";
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 { PaymentMethodType } from "@bitwarden/common/billing/enums";
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 { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
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 {
AddCreditDialogResult,
@ -25,26 +33,36 @@ import {
@Component({
templateUrl: "./organization-payment-method.component.html",
})
export class OrganizationPaymentMethodComponent {
export class OrganizationPaymentMethodComponent implements OnDestroy {
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
organizationId: string;
isUnpaid = false;
accountCredit: number;
paymentSource?: PaymentSourceResponse;
subscriptionStatus?: string;
protected freeTrialData: FreeTrial;
organization: Organization;
organizationSubscriptionResponse: OrganizationSubscriptionResponse;
loading = true;
protected readonly Math = Math;
launchPaymentModalAutomatically = false;
constructor(
private activatedRoute: ActivatedRoute,
private billingApiService: BillingApiServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private toastService: ToastService,
private location: Location,
private trialFlowService: TrialFlowService,
private organizationService: OrganizationService,
protected syncService: SyncService,
) {
this.activatedRoute.params
.pipe(
@ -59,6 +77,23 @@ export class OrganizationPaymentMethodComponent {
}),
)
.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> => {
@ -82,6 +117,34 @@ export class OrganizationPaymentMethodComponent {
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
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;
};
@ -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> => {
this.taxInfoComponent.taxFormGroup.updateValueAndValidity();
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 new Promise((resolve) => setTimeout(resolve, 10000));
this.toastService.showToast({
variant: "success",
title: null,

View File

@ -1,5 +1,7 @@
import { NgModule } from "@angular/core";
import { BannerModule } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
@ -27,6 +29,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
PaymentComponent,
TaxInfoComponent,
HeaderModule,
BannerModule,
PaymentV2Component,
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">
<button
type="button"
@ -77,7 +97,13 @@
{{ paymentSource.description }}
</p>
</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 }}
</button>
<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 { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.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 { LogService } from "@bitwarden/common/platform/abstractions/log.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 { FreeTrial } from "../../core/types/free-trial";
import { TrialFlowService } from "../services/trial-flow.service";
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
import {
AdjustPaymentDialogResult,
@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component";
templateUrl: "payment-method.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class PaymentMethodComponent implements OnInit {
export class PaymentMethodComponent implements OnInit, OnDestroy {
@ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent;
loading = false;
@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit {
paymentMethodType = PaymentMethodType;
organizationId: string;
isUnpaid = false;
organization: Organization;
verifyBankForm = this.formBuilder.group({
amount1: new FormControl<number>(null, [
@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit {
});
taxForm = this.formBuilder.group({});
launchPaymentModalAutomatically = false;
protected freeTrialData: FreeTrial;
constructor(
protected apiService: ApiService,
@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit {
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
private router: Router,
private location: Location,
private logService: LogService,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private dialogService: DialogService,
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() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit {
return;
}
this.loading = true;
if (this.forOrganization) {
const billingPromise = this.organizationApiService.getBilling(this.organizationId);
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
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,
organizationSubscriptionPromise,
organizationPromise,
]);
this.determineOrgsWithUpcomingPaymentIssues();
} else {
const billingPromise = this.apiService.getUserBillingPayment();
const subPromise = this.apiService.getUserSubscription();
[this.billing, this.sub] = await Promise.all([billingPromise, subPromise]);
}
this.isUnpaid = this.subscription?.status === "unpaid" ?? 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 () => {
@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit {
});
const result = await lastValueFrom(dialogRef.closed);
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();
}
};
@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit {
});
};
determineOrgsWithUpcomingPaymentIssues() {
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.org,
this.billing?.paymentSource,
);
}
get isCreditBalance() {
return this.billing == null || this.billing.balance <= 0;
}
@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit {
get subscription() {
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]"
(mainContentClicked)="toggle()"
[routerLinkActiveOptions]="{ exact: true }"
(click)="handleUnpaidSubscription(org)"
>
<i
slot="end"

View File

@ -6,7 +6,10 @@ import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
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({
selector: "org-switcher",
@ -52,7 +55,10 @@ export class OrgSwitcherComponent {
constructor(
private route: ActivatedRoute,
protected dialogService: DialogService,
private organizationService: OrganizationService,
private trialFlowService: TrialFlowService,
protected billingApiService: BillingApiServiceAbstraction,
) {}
protected toggle(event?: MouseEvent) {
@ -60,4 +66,9 @@ export class OrgSwitcherComponent {
this.open = !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
id="update-browser-banner"
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BannerModule } from "@bitwarden/components";
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
import { FreeTrial } from "../../../core/types/free-trial";
import { SharedModule } from "../../../shared";
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
@ -19,8 +22,13 @@ export class VaultBannersComponent implements OnInit {
visibleBanners: VisibleVaultBanner[] = [];
premiumBannerVisible$: Observable<boolean>;
VisibleVaultBanner = VisibleVaultBanner;
@Input() organizationsPaymentStatus: FreeTrial[] = [];
constructor(private vaultBannerService: VaultBannersService) {
constructor(
private vaultBannerService: VaultBannersService,
private router: Router,
private i18nService: I18nService,
) {
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
}
@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit {
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 */
private async determineVisibleBanners(): Promise<void> {
const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner();
@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit {
showLowKdf ? VisibleVaultBanner.KDFSettings : 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService } from "@bitwarden/components";
import { VaultFilterService } from "../services/abstractions/vault-filter.service";
import {
@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
isLoaded = false;
protected destroy$: Subject<void> = new Subject<void>();
private router = inject(Router);
get filtersList() {
return this.filters ? Object.values(this.filters) : [];
}
@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected policyService: PolicyService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
) {}
async ngOnInit(): Promise<void> {
@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
null,
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;
}
const filter = this.activeFilter;
@ -123,6 +136,32 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
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> => {
const filter = this.activeFilter;
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
[filter]="filter"

View File

@ -13,6 +13,7 @@ import {
BehaviorSubject,
combineLatest,
firstValueFrom,
from,
lastValueFrom,
Observable,
Subject,
@ -41,10 +42,12 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@ -72,6 +75,8 @@ import {
PasswordRepromptService,
} 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 { AssignCollectionsWebComponent } from "../components/assign-collections";
import {
@ -174,12 +179,28 @@ export class VaultComponent implements OnInit, OnDestroy {
protected canCreateCollections = false;
protected currentSearchText$: Observable<string>;
private activeUserId: UserId;
protected organizationsPaymentStatus: FreeTrial[] = [];
private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
private extensionRefreshEnabled: boolean;
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(
private syncService: SyncService,
@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private accountService: AccountService,
private cipherFormConfigService: DefaultCipherFormConfigService,
private organizationApiService: OrganizationApiServiceAbstraction,
protected billingApiService: BillingApiServiceAbstraction,
private trialFlowService: TrialFlowService,
) {}
async ngOnInit() {
@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy {
if (filter.collectionId === undefined || filter.collectionId === Unassigned) {
return [];
}
let collectionsToReturn = [];
if (filter.organizationId !== undefined && filter.collectionId === All) {
collectionsToReturn = collections
@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy {
filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled),
switchMap(async (params) => {
const cipherId = getCipherIdFromParams(params);
if (cipherId) {
if (await this.cipherService.get(cipherId)) {
let action = params.action;
@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.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$
.pipe(
switchMap(() => this.refresh$),
@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy {
ciphers$,
collections$,
selectedCollection$,
organizationsPaymentStatus$,
]),
),
takeUntil(this.destroy$),
@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy {
ciphers,
collections,
selectedCollection,
organizationsPaymentStatus,
]) => {
this.filter = filter;
this.canAccessPremium = canAccessPremium;
@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.showBulkMove = filter.type !== "trash";
this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
this.organizationsPaymentStatus = organizationsPaymentStatus;
this.performingInitialLoad = 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 { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
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 { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service";
@ -38,8 +40,17 @@ export class VaultFilterComponent
protected policyService: PolicyService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
) {
super(vaultFilterService, policyService, i18nService, platformUtilsService);
super(
vaultFilterService,
policyService,
i18nService,
platformUtilsService,
billingApiService,
dialogService,
);
}
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
[filter]="filter"
[loading]="refreshing"

View File

@ -13,8 +13,10 @@ import {
BehaviorSubject,
combineLatest,
firstValueFrom,
from,
lastValueFrom,
Observable,
of,
Subject,
} from "rxjs";
import {
@ -43,8 +45,10 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.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 { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
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 {
CipherFormConfig,
CipherFormConfigService,
@ -73,6 +83,8 @@ import {
import { GroupService, GroupView } from "../../admin-console/organizations/core";
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 { 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";
@ -130,6 +142,7 @@ enum AddAccessStatusType {
VaultFilterModule,
VaultItemsModule,
SharedModule,
BannerModule,
NoItemsModule,
],
providers: [
@ -166,6 +179,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected isEmpty: boolean;
protected showCollectionAccessRestricted: boolean;
protected currentSearchText$: Observable<string>;
protected freeTrial$: Observable<FreeTrial>;
/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy {
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private extensionRefreshEnabled: boolean;
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(
private route: ActivatedRoute,
@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private configService: ConfigService,
private cipherFormConfigService: CipherFormConfigService,
private organizationApiService: OrganizationApiServiceAbstraction,
private trialFlowService: TrialFlowService,
protected billingApiService: BillingApiServiceAbstraction,
) {}
async ngOnInit() {
@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.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$
.pipe(
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) {
this.addAccessStatus$.next(e);
}

View File

@ -3837,6 +3837,55 @@
"updateBrowserDesc": {
"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": {
"message": "Join organization"
},
@ -8444,7 +8493,7 @@
},
"addAPaymentMethod": {
"message": "add a payment method",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'"
"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": {
"message": "Organization information"
@ -9631,5 +9680,20 @@
"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">
<sm-new-menu></sm-new-menu>
</app-header>

View File

@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import {
map,
Observable,
@ -12,14 +12,20 @@ import {
take,
share,
firstValueFrom,
concatMap,
of,
filter,
} 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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
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 { ProjectListView } from "../models/view/project-list.view";
@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy {
protected showOnboarding = false;
protected loading = true;
protected organizationEnabled = false;
protected organization: Organization;
protected i18n: I18nPipe;
protected onboardingTasks$: Observable<SMOnboardingTasks>;
protected view$: Observable<{
@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
tasks: OrganizationTasks;
counts: OrganizationCounts;
}>;
protected freeTrial$: Observable<FreeTrial>;
constructor(
private route: ActivatedRoute,
@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy {
private i18nService: I18nService,
private smOnboardingTasksService: SMOnboardingTasksService,
private logService: LogService,
private router: Router,
private organizationApiService: OrganizationApiServiceAbstraction,
private trialFlowService: TrialFlowService,
) {}
ngOnInit() {
@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy {
distinctUntilChanged(),
);
orgId$
.pipe(
concatMap(async (orgId) => await this.organizationService.get(orgId)),
takeUntil(this.destroy$),
)
.subscribe((org) => {
this.organizationId = org.id;
this.organizationName = org.name;
this.userIsAdmin = org.isAdmin;
this.loading = true;
this.organizationEnabled = org.enabled;
});
const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId)));
org$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
this.organizationId = org.id;
this.organization = org;
this.organizationName = org.name;
this.userIsAdmin = org.isAdmin;
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([
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 {
this.destroy$.next();
this.destroy$.complete();

View File

@ -1,5 +1,7 @@
import { NgModule } from "@angular/core";
import { BannerModule } from "@bitwarden/components";
import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component";
import { SectionComponent } from "./section.component";
@NgModule({
imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule],
imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule],
declarations: [OverviewComponent, SectionComponent],
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 { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
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 { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction {
getLicense: (id: string, installationId: string) => Promise<unknown>;
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
createWithoutPayment: (
request: OrganizationNoPaymentMethodCreateRequest,
) => Promise<OrganizationResponse>;
createLicense: (data: FormData) => Promise<OrganizationResponse>;
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
updatePayment: (id: string, request: PaymentRequest) => Promise<void>;

View File

@ -1,32 +1,7 @@
import { PaymentMethodType, PlanType } from "../../../billing/enums";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { PaymentMethodType } from "../../../billing/enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationKeysRequest } from "./organization-keys.request";
export class OrganizationCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest {
paymentMethodType: PaymentMethodType;
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 { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
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 { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
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> {
const r = await this.apiService.send(
"POST",

View File

@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction {
purchaseSubscription: (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;
isManaged: boolean;
isOnSecretsManagerStandalone: boolean;
isSubscriptionUnpaid: boolean;
constructor(response: any) {
super(response);
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
}
}

View File

@ -17,6 +17,7 @@ import {
SubscriptionInformation,
} from "../abstractions/organization-billing.service";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
interface OrganizationKeys {
encryptedKey: EncString;
@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
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> {
const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>();
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key);
@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
}
private setOrganizationInformation(
request: OrganizationCreateRequest,
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: OrganizationInformation,
): void {
request.name = information.name;
@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
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.keys = new OrganizationKeysRequest(
keys.publicKey,
@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
}
private setPlanInformation(
request: OrganizationCreateRequest,
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: PlanInformation,
): void {
request.planType = information.type;

View File

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