1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

[PM-24032] - adds a new upgrade plan button to side nav (#16687)

* feat(billing): add required messages

* feat(billing): Add upgrade from free account dialog

* feat(billing): Add payment dialog for premium upgrade

* feat(billing): Add Upgrade Payment Service

* feat(billing): Add Upgrade flow service

* feat(billing): Add purchase premium subscription method to client

* fix(billing): allow for nullable taxId for families organizations

* fix(billing): Fix Cart Summary Tests

* temp-fix(billing): add currency pipe to pricing card component

* fix(billing): Fix NX error

This should compile just the library files and not its dependency files which was making it error

* [PM-24032] new premium upgrade button in navigation

* add missing outline none styles to focus state

* update story to use correct background colors. change ring color

* fixing broken stories by mocking services

* updates requested by design and product

* fixing tests

* new icon for premium

* fix: Update any type of private function

* update account dialog

* [PM-24032] updates to premium upgrade nav button

* add margin bottom to prevent button focus outline from being chopped off

* adding missing test

* feat(billing): add upgrade error message

* fix(billing): remove upgrade-flow service

* feat(billing): add account billing client

* fix(billing): Remove method from subscriber-billing client

* fix(billing): rename and update upgrade payment component

* fix(billing): Rename and update upgrade payment service

* fix(billing): Rename and upgrade upgrade account component

* fix(billing): Add unified upgrade dialog component

* fix(billing): Update component and service to use new tax service

* fix(billing): Update unified upgrade dialog

* feat(billing): Add feature flag

* feat(billing): Add vault dialog launch logic

* fix(billing): Add stricter validation for payment component

* fix(billing): Update custom dialog close button

* fix(billing): Fix padding in cart summary component

* fix(billing): Update payment method component spacing

* fix(billing): Update Upgrade Payment component spacing

* fix(billing): Update upgrade account component spacing

* fix(billing): Fix accurate typing

* hide continue button when coming from nav upgrade button

* fixing incorrect conflict resolution

* fixing tests

* pr feedback

* removing duplicate icon definition

---------

Co-authored-by: Stephon Brown <sbrown@livefront.com>
Co-authored-by: Bryan Cunningham <bryan.cunningham@me.com>
This commit is contained in:
Kyle Denney
2025-10-14 10:39:37 -05:00
committed by GitHub
parent 31aa5f4d5b
commit e65d572401
24 changed files with 914 additions and 31 deletions

View File

@@ -1,5 +1,10 @@
@if (step() == PlanSelectionStep) {
<app-upgrade-account (planSelected)="onPlanSelected($event)" (closeClicked)="onCloseClicked()" />
<app-upgrade-account
[dialogTitleMessageOverride]="planSelectionStepTitleOverride()"
[hideContinueWithoutUpgradingButton]="hideContinueWithoutUpgradingButton()"
(planSelected)="onPlanSelected($event)"
(closeClicked)="onCloseClicked()"
/>
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
<app-upgrade-payment
[selectedPlanId]="selectedPlan()"

View File

@@ -0,0 +1,240 @@
import { Component, input, output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import {
UpgradeAccountComponent,
UpgradeAccountStatus,
} from "../upgrade-account/upgrade-account.component";
import {
UpgradePaymentComponent,
UpgradePaymentResult,
} from "../upgrade-payment/upgrade-payment.component";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
UnifiedUpgradeDialogStep,
} from "./unified-upgrade-dialog.component";
@Component({
selector: "app-upgrade-account",
template: "",
standalone: true,
})
class MockUpgradeAccountComponent {
dialogTitleMessageOverride = input<string | null>(null);
hideContinueWithoutUpgradingButton = input<boolean>(false);
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
}
@Component({
selector: "app-upgrade-payment",
template: "",
standalone: true,
})
class MockUpgradePaymentComponent {
selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
account = input<Account | null>(null);
goBack = output<void>();
complete = output<UpgradePaymentResult>();
}
describe("UnifiedUpgradeDialogComponent", () => {
let component: UnifiedUpgradeDialogComponent;
let fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
};
const defaultDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: null,
selectedPlan: null,
planSelectionStepTitleOverride: null,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize with default values", () => {
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
expect(component["account"]()).toEqual(mockAccount);
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
});
it("should initialize with custom initial step", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
});
describe("custom dialog title", () => {
it("should use null as default when no override is provided", () => {
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
});
it("should use custom title when provided in dialog config", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.PlanSelection,
selectedPlan: null,
planSelectionStepTitleOverride: "upgradeYourPlan",
};
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan");
});
});
describe("onPlanSelected", () => {
it("should set selected plan and move to payment step", () => {
component["onPlanSelected"](PersonalSubscriptionPricingTierIds.Premium);
expect(component["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
});
});
describe("previousStep", () => {
it("should go back to plan selection and clear selected plan", () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
component["previousStep"]();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
});
});
describe("hideContinueWithoutUpgradingButton", () => {
it("should default to false when not provided", () => {
expect(component["hideContinueWithoutUpgradingButton"]()).toBe(false);
});
it("should be set to true when provided in dialog config", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: null,
selectedPlan: null,
hideContinueWithoutUpgradingButton: true,
};
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
});
});
});

View File

@@ -48,11 +48,15 @@ export type UnifiedUpgradeDialogResult = {
* @property {Account} account - The user account information.
* @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any.
* @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
* @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title.
* @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button.
*/
export type UnifiedUpgradeDialogParams = {
account: Account;
initialStep?: UnifiedUpgradeDialogStep | null;
selectedPlan?: PersonalSubscriptionPricingTierId | null;
planSelectionStepTitleOverride?: string | null;
hideContinueWithoutUpgradingButton?: boolean;
};
@Component({
@@ -73,6 +77,8 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
protected account = signal<Account | null>(null);
protected planSelectionStepTitleOverride = signal<string | null>(null);
protected hideContinueWithoutUpgradingButton = signal<boolean>(false);
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
@@ -86,6 +92,10 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.account.set(this.params.account);
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(this.params.selectedPlan ?? null);
this.planSelectionStepTitleOverride.set(this.params.planSelectionStepTitleOverride ?? null);
this.hideContinueWithoutUpgradingButton.set(
this.params.hideContinueWithoutUpgradingButton ?? false,
);
}
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {

View File

@@ -17,7 +17,7 @@
<div class="tw-px-14 tw-pb-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-semibold tw-text-[32px]">
{{ "individualUpgradeWelcomeMessage" | i18n }}
{{ dialogTitle() | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">
{{ "individualUpgradeDescriptionMessage" | i18n }}
@@ -59,9 +59,11 @@
<p bitTypography="helper" class="tw-text-muted tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
{{ "continueWithoutUpgrading" | i18n }}
</button>
@if (!hideContinueWithoutUpgradingButton()) {
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
{{ "continueWithoutUpgrading" | i18n }}
</button>
}
</div>
</div>
</section>

View File

@@ -146,4 +146,46 @@ describe("UpgradeAccountComponent", () => {
expect(result).toBe(false);
});
});
describe("hideContinueWithoutUpgradingButton", () => {
it("should show the continue without upgrading button by default", () => {
const button = fixture.nativeElement.querySelector('button[bitLink][linkType="primary"]');
expect(button).toBeTruthy();
});
it("should hide the continue without upgrading button when input is true", async () => {
TestBed.resetTestingModule();
mockI18nService.t.mockImplementation((key) => key);
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of(mockPricingTiers),
);
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
UpgradeAccountComponent,
PricingCardComponent,
CdkTrapFocus,
],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
],
})
.overrideComponent(UpgradeAccountComponent, {
remove: { imports: [BillingServicesModule] },
})
.compileComponents();
const customFixture = TestBed.createComponent(UpgradeAccountComponent);
customFixture.componentRef.setInput("hideContinueWithoutUpgradingButton", true);
customFixture.detectChanges();
const button = customFixture.nativeElement.querySelector(
'button[bitLink][linkType="primary"]',
);
expect(button).toBeNull();
});
});
});

View File

@@ -1,6 +1,6 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, output, signal } from "@angular/core";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -52,6 +52,8 @@ type CardDetails = {
templateUrl: "./upgrade-account.component.html",
})
export class UpgradeAccountComponent implements OnInit {
dialogTitleMessageOverride = input<string | null>(null);
hideContinueWithoutUpgradingButton = input<boolean>(false);
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
protected loading = signal(true);
@@ -62,6 +64,10 @@ export class UpgradeAccountComponent implements OnInit {
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
protected closeStatus = UpgradeAccountStatus.Closed;
protected dialogTitle = computed(() => {
return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage";
});
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,

View File

@@ -0,0 +1,14 @@
<div class="tw-px-2 tw-mt-3 tw-mb-2 tw-h-10">
<div class="tw-rounded-full tw-bg-primary-100 tw-size-full">
<!-- Note that this is a custom button style for premium upgrade because the style desired
is not supported by the button in the CL. -->
<button
type="button"
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-semibold tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
(click)="openUpgradeDialog()"
>
<i class="bwi bwi-premium" aria-hidden="true"></i>
{{ "upgradeYourPlan" | i18n }}
</button>
</div>
</div>

View File

@@ -0,0 +1,36 @@
import { Component, inject } from "@angular/core";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { UnifiedUpgradeDialogComponent } from "../../unified-upgrade-dialog/unified-upgrade-dialog.component";
@Component({
selector: "app-upgrade-nav-button",
imports: [I18nPipe],
templateUrl: "./upgrade-nav-button.component.html",
standalone: true,
})
export class UpgradeNavButtonComponent {
private dialogService = inject(DialogService);
private accountService = inject(AccountService);
openUpgradeDialog = async () => {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: {
account,
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
await lastValueFrom(dialogRef.closed);
};
}

View File

@@ -0,0 +1,65 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, I18nMockService } from "@bitwarden/components";
import { UpgradeNavButtonComponent } from "@bitwarden/web-vault/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component";
export default {
title: "Billing/Upgrade Navigation Button",
component: UpgradeNavButtonComponent,
decorators: [
moduleMetadata({
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
upgradeYourPlan: "Upgrade your plan",
});
},
},
{
provide: DialogService,
useValue: {
open: () => ({
closed: of({}),
}),
},
},
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
}),
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=858-44274&t=EiNqDGuccfhF14on-1",
},
},
} as Meta;
type Story = StoryObj<UpgradeNavButtonComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-p-4 tw-bg-background-alt3">
<app-upgrade-nav-button></app-upgrade-nav-button>
</div>
`,
}),
};

View File

@@ -1,8 +1,14 @@
import { TestBed } from "@angular/core/testing";
import { mock, mockReset } from "jest-mock-extended";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -22,6 +28,8 @@ describe("UpgradePaymentService", () => {
const mockLogService = mock<LogService>();
const mockApiService = mock<ApiService>();
const mockSyncService = mock<SyncService>();
const mockOrganizationService = mock<OrganizationService>();
const mockAccountService = mock<AccountService>();
mockApiService.refreshIdentityToken.mockResolvedValue({});
mockSyncService.fullSync.mockResolvedValue(true);
@@ -94,6 +102,11 @@ describe("UpgradePaymentService", () => {
mockReset(mockAccountBillingClient);
mockReset(mockTaxClient);
mockReset(mockLogService);
mockReset(mockOrganizationService);
mockReset(mockAccountService);
mockAccountService.activeAccount$ = of(null);
mockOrganizationService.organizations$.mockReturnValue(of([]));
TestBed.configureTestingModule({
providers: [
@@ -108,12 +121,204 @@ describe("UpgradePaymentService", () => {
{ provide: LogService, useValue: mockLogService },
{ provide: ApiService, useValue: mockApiService },
{ provide: SyncService, useValue: mockSyncService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
],
});
sut = TestBed.inject(UpgradePaymentService);
});
describe("userIsOwnerOfFreeOrg$", () => {
it("should return true when user is owner of a free organization", (done) => {
// Arrange
mockReset(mockAccountService);
mockReset(mockOrganizationService);
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
};
const paidOrgData = {
id: "org-1",
name: "Paid Org",
useTotp: true, // useTotp = true means NOT free
type: OrganizationUserType.Owner,
} as OrganizationData;
const freeOrgData = {
id: "org-2",
name: "Free Org",
useTotp: false, // useTotp = false means IS free
type: OrganizationUserType.Owner,
} as OrganizationData;
const paidOrg = new Organization(paidOrgData);
const freeOrg = new Organization(freeOrgData);
const mockOrganizations = [paidOrg, freeOrg];
mockAccountService.activeAccount$ = of(mockAccount);
mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations));
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
);
// Act & Assert
service.userIsOwnerOfFreeOrg$.subscribe((result) => {
expect(result).toBe(true);
done();
});
});
it("should return false when user is not owner of any free organization", (done) => {
// Arrange
mockReset(mockAccountService);
mockReset(mockOrganizationService);
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
};
const paidOrgData = {
id: "org-1",
name: "Paid Org",
useTotp: true, // useTotp = true means NOT free
type: OrganizationUserType.Owner,
} as OrganizationData;
const freeOrgData = {
id: "org-2",
name: "Free Org",
useTotp: false, // useTotp = false means IS free
type: OrganizationUserType.User, // Not owner
} as OrganizationData;
const paidOrg = new Organization(paidOrgData);
const freeOrg = new Organization(freeOrgData);
const mockOrganizations = [paidOrg, freeOrg];
mockAccountService.activeAccount$ = of(mockAccount);
mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations));
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
);
// Act & Assert
service.userIsOwnerOfFreeOrg$.subscribe((result) => {
expect(result).toBe(false);
done();
});
});
it("should return false when user has no organizations", (done) => {
// Arrange
mockReset(mockAccountService);
mockReset(mockOrganizationService);
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
};
mockAccountService.activeAccount$ = of(mockAccount);
mockOrganizationService.organizations$.mockReturnValue(of([]));
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
);
// Act & Assert
service.userIsOwnerOfFreeOrg$.subscribe((result) => {
expect(result).toBe(false);
done();
});
});
});
describe("adminConsoleRouteForOwnedOrganization$", () => {
it("should return the admin console route for the first free organization the user owns", (done) => {
// Arrange
mockReset(mockAccountService);
mockReset(mockOrganizationService);
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
};
const paidOrgData = {
id: "org-1",
name: "Paid Org",
useTotp: true, // useTotp = true means NOT free
type: OrganizationUserType.Owner,
} as OrganizationData;
const freeOrgData = {
id: "org-2",
name: "Free Org",
useTotp: false, // useTotp = false means IS free
type: OrganizationUserType.Owner,
} as OrganizationData;
const paidOrg = new Organization(paidOrgData);
const freeOrg = new Organization(freeOrgData);
const mockOrganizations = [paidOrg, freeOrg];
mockAccountService.activeAccount$ = of(mockAccount);
mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations));
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
);
// Act & Assert
service.adminConsoleRouteForOwnedOrganization$.subscribe((result) => {
expect(result).toBe("/organizations/org-2/billing/subscription");
done();
});
});
});
describe("calculateEstimatedTax", () => {
it("should calculate tax for premium plan", async () => {
// Arrange

View File

@@ -1,8 +1,12 @@
import { Injectable } from "@angular/core";
import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
OrganizationBillingServiceAbstraction,
SubscriptionInformation,
@@ -53,8 +57,28 @@ export class UpgradePaymentService {
private logService: LogService,
private apiService: ApiService,
private syncService: SyncService,
private organizationService: OrganizationService,
private accountService: AccountService,
) {}
userIsOwnerOfFreeOrg$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((id) => this.organizationService.organizations$(id)),
mergeMap((userOrganizations) => userOrganizations),
find((org) => org.isFreeOrg && org.isOwner),
defaultIfEmpty(false),
map((value) => value instanceof Organization),
);
adminConsoleRouteForOwnedOrganization$: Observable<string> =
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((id) => this.organizationService.organizations$(id)),
mergeMap((userOrganizations) => userOrganizations),
find((org) => org.isFreeOrg && org.isOwner),
map((org) => `/organizations/${org!.id}/billing/subscription`),
);
/**
* Calculate estimated tax for the selected plan
*/

View File

@@ -4,6 +4,22 @@
<ng-container bitDialogContent>
<section>
@if (isFamiliesPlan) {
@if (userIsOwnerOfFreeOrg$ | async) {
<div class="tw-pb-2">
<bit-callout type="info">
{{ "formWillCreateNewFamiliesOrgMessage" | i18n }}
<a
bitLink
bitDialogClose
linkType="primary"
[routerLink]="adminConsoleRouteForOwnedOrganization$ | async"
>
{{ "upgradeNow" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-callout>
</div>
}
<div class="tw-pb-4">
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "organizationName" | i18n }}</bit-label>

View File

@@ -104,6 +104,10 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
private upgradePaymentService: UpgradePaymentService,
) {}
protected userIsOwnerOfFreeOrg$ = this.upgradePaymentService.userIsOwnerOfFreeOrg$;
protected adminConsoleRouteForOwnedOrganization$ =
this.upgradePaymentService.adminConsoleRouteForOwnedOrganization$;
async ngOnInit(): Promise<void> {
if (!this.isFamiliesPlan) {
this.formGroup.controls.organizationName.disable();

View File

@@ -1,19 +1,25 @@
<div class="tw-mt-auto">
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts$ | async"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
>
</bit-nav-item>
<ng-container *ngIf="moreProducts$ | async as moreProducts">
<section
*ngIf="moreProducts.length > 0"
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0"
@let accessibleProducts = accessibleProducts$ | async;
@if (accessibleProducts && accessibleProducts.length > 1) {
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
>
</bit-nav-item>
}
@if (shouldShowPremiumUpgradeButton$ | async) {
<app-upgrade-nav-button></app-upgrade-nav-button>
}
@let moreProducts = moreProducts$ | async;
@if (moreProducts && moreProducts.length > 0) {
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<ng-container *ngFor="let more of moreProducts">
<div class="tw-ps-2 tw-pe-2">
@@ -57,5 +63,5 @@
</div>
</ng-container>
</section>
</ng-container>
}
</div>

View File

@@ -1,3 +1,4 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute, RouterModule } from "@angular/router";
@@ -15,6 +16,13 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
@Component({
selector: "app-upgrade-nav-button",
template: "<div>Upgrade Nav Button</div>",
standalone: true,
})
class MockUpgradeNavButtonComponent {}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
@@ -41,13 +49,16 @@ describe("NavigationProductSwitcherComponent", () => {
other: [],
});
const mockShouldShowPremiumUpgradeButton$ = new BehaviorSubject<boolean>(false);
beforeEach(async () => {
productSwitcherService = mock<ProductSwitcherService>();
productSwitcherService.products$ = mockProducts$;
productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$;
mockProducts$.next({ bento: [], other: [] });
await TestBed.configureTestingModule({
imports: [RouterModule, NavigationModule, IconButtonModule],
imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent],
declarations: [NavigationProductSwitcherComponent, I18nPipe],
providers: [
{ provide: ProductSwitcherService, useValue: productSwitcherService },
@@ -187,15 +198,23 @@ describe("NavigationProductSwitcherComponent", () => {
},
isActive: true,
},
{
name: "Test Product",
icon: "bwi-lock",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
},
],
other: [],
});
fixture.detectChanges();
const navItem = fixture.debugElement.query(By.directive(NavItemComponent));
const navItem = fixture.debugElement.queryAll(By.directive(NavItemComponent));
expect(navItem.componentInstance.forceActiveStyles()).toBe(true);
expect(navItem[0].componentInstance.forceActiveStyles()).toBe(true);
});
});
@@ -218,18 +237,56 @@ describe("NavigationProductSwitcherComponent", () => {
expect(links[0].textContent).toContain("Password Manager");
expect(links[1].textContent).toContain("Secret Manager");
});
it("does not show products list when there is only one item", () => {
mockProducts$.next({
bento: [{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
other: [],
});
fixture.detectChanges();
const navItems = fixture.debugElement.queryAll(By.directive(NavItemComponent));
expect(navItems.length).toBe(0);
});
});
it("links to `appRoute`", () => {
mockProducts$.next({
bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
bento: [
{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
],
other: [],
});
fixture.detectChanges();
const link = fixture.nativeElement.querySelector("a");
const links = fixture.nativeElement.querySelectorAll("a");
expect(link.getAttribute("href")).toBe("/vault");
expect(links[0].getAttribute("href")).toBe("/vault");
});
describe("upgrade nav button", () => {
it("shows upgrade nav button when shouldShowPremiumUpgradeButton$ is true", () => {
mockShouldShowPremiumUpgradeButton$.next(true);
mockProducts$.next({
bento: [],
other: [
{
name: "Organizations",
icon: "bwi-lock",
marketingRoute: { route: "https://www.example.com/", external: true },
},
],
});
fixture.detectChanges();
const upgradeButton = fixture.nativeElement.querySelector("app-upgrade-nav-button");
expect(upgradeButton).toBeTruthy();
});
});
});

View File

@@ -11,6 +11,9 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
export class NavigationProductSwitcherComponent {
constructor(private productSwitcherService: ProductSwitcherService) {}
protected readonly shouldShowPremiumUpgradeButton$: Observable<boolean> =
this.productSwitcherService.shouldShowPremiumUpgradeButton$;
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(map((products) => products.bento ?? []));

View File

@@ -9,6 +9,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
}
}
class MockBillingAccountProfileStateService implements Partial<BillingAccountProfileStateService> {
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
return of(false);
}
}
class MockConfigService implements Partial<ConfigService> {
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
return of(false);
}
}
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
@@ -117,6 +132,11 @@ export default {
{ provide: ProviderService, useClass: MockProviderService },
{ provide: SyncService, useClass: MockSyncService },
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
{
provide: BillingAccountProfileStateService,
useClass: MockBillingAccountProfileStateService,
},
{ provide: ConfigService, useClass: MockConfigService },
ProductSwitcherService,
{
provide: I18nService,

View File

@@ -5,6 +5,7 @@ import { RouterModule } from "@angular/router";
import { NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { UpgradeNavButtonComponent } from "../../billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component";
import { SharedModule } from "../../shared";
import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component";
@@ -12,7 +13,14 @@ import { ProductSwitcherContentComponent } from "./product-switcher-content.comp
import { ProductSwitcherComponent } from "./product-switcher.component";
@NgModule({
imports: [SharedModule, A11yModule, RouterModule, NavigationModule, I18nPipe],
imports: [
SharedModule,
A11yModule,
RouterModule,
NavigationModule,
I18nPipe,
UpgradeNavButtonComponent,
],
declarations: [
ProductSwitcherComponent,
ProductSwitcherContentComponent,

View File

@@ -10,6 +10,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
}
}
class MockBillingAccountProfileStateService implements Partial<BillingAccountProfileStateService> {
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
return of(false);
}
}
class MockConfigService implements Partial<ConfigService> {
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
return of(false);
}
}
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
@@ -114,6 +129,11 @@ export default {
MockProviderService,
{ provide: SyncService, useClass: MockSyncService },
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
{
provide: BillingAccountProfileStateService,
useClass: MockBillingAccountProfileStateService,
},
{ provide: ConfigService, useClass: MockConfigService },
MockPlatformUtilsService,
ProductSwitcherService,
{

View File

@@ -11,6 +11,8 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -27,6 +29,8 @@ describe("ProductSwitcherService", () => {
let providerService: MockProxy<ProviderService>;
let accountService: FakeAccountService;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let configService: MockProxy<ConfigService>;
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
let singleOrgPolicyEnabled = false;
const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14"));
@@ -48,6 +52,8 @@ describe("ProductSwitcherService", () => {
providerService = mock<ProviderService>();
accountService = mockAccountServiceWith(userId);
platformUtilsService = mock<PlatformUtilsService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
configService = mock<ConfigService>();
router.url = "/";
router.events = of({});
@@ -85,6 +91,8 @@ describe("ProductSwitcherService", () => {
policyAppliesToUser$: () => of(singleOrgPolicyEnabled),
},
},
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
{ provide: ConfigService, useValue: configService },
],
});
});
@@ -325,4 +333,57 @@ describe("ProductSwitcherService", () => {
expect(appRoute).toEqual(["/organizations", "111-22-33"]);
});
describe("shouldShowPremiumUpgradeButton$", () => {
it("returns false when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
initiateService();
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
expect(shouldShow).toBe(false);
});
it("returns false when there is no active account", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
accountService.activeAccount$ = of(null);
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
initiateService();
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
expect(shouldShow).toBe(false);
});
it("returns true when feature flag is enabled, account exists, and user has no premium", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
initiateService();
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
expect(shouldShow).toBe(true);
expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId,
);
});
it("returns false when feature flag is enabled, account exists, but user has premium", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
initiateService();
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
expect(shouldShow).toBe(false);
expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId,
);
});
});
});

View File

@@ -2,7 +2,16 @@
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router";
import { combineLatest, filter, map, Observable, ReplaySubject, startWith, switchMap } from "rxjs";
import {
combineLatest,
filter,
map,
Observable,
of,
ReplaySubject,
startWith,
switchMap,
} from "rxjs";
import {
canAccessOrgAdmin,
@@ -15,6 +24,9 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -99,6 +111,8 @@ export class ProductSwitcherService {
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
private i18nService: I18nService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
) {
this.pollUntilSynced();
}
@@ -118,6 +132,20 @@ export class ProductSwitcherService {
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
);
shouldShowPremiumUpgradeButton$: Observable<boolean> = combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton),
this.accountService.activeAccount$,
]).pipe(
switchMap(([featureFlag, account]) => {
if (!featureFlag || !account) {
return of(false);
}
return this.billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => !hasPremium));
}),
);
products$: Observable<{
bento: ProductSwitcherItem[];
other: ProductSwitcherItem[];

View File

@@ -91,6 +91,7 @@ import {
DefaultCipherFormConfigService,
PasswordRepromptService,
} from "@bitwarden/vault";
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
@@ -103,7 +104,6 @@ import {
CollectionDialogTabType,
openCollectionDialog,
} from "../../admin-console/organizations/shared/components/collection-dialog";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services/unified-upgrade-prompt.service";
import { SharedModule } from "../../shared/shared.module";
import { AssignCollectionsWebComponent } from "../components/assign-collections";
import {

View File

@@ -11805,6 +11805,15 @@
"continueWithoutUpgrading": {
"message": "Continue without upgrading"
},
"upgradeYourPlan": {
"message": "Upgrade your plan"
},
"upgradeNow": {
"message": "Upgrade now"
},
"formWillCreateNewFamiliesOrgMessage": {
"message": "Completing this form will create a new Families organization. You can upgrade your Free organization from the Admin Console."
},
"upgradeErrorMessage": {
"message": "We encountered an error while processing your upgrade. Please try again."
}

View File

@@ -25,6 +25,7 @@ export enum FeatureFlag {
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
@@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,