diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html index 3e7b797f00f..83c940da97f 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html @@ -1,5 +1,10 @@ @if (step() == PlanSelectionStep) { - + } @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) { (null); + hideContinueWithoutUpgradingButton = input(false); + planSelected = output(); + closeClicked = output(); +} + +@Component({ + selector: "app-upgrade-payment", + template: "", + standalone: true, +}) +class MockUpgradePaymentComponent { + selectedPlanId = input(null); + account = input(null); + goBack = output(); + complete = output(); +} + +describe("UnifiedUpgradeDialogComponent", () => { + let component: UnifiedUpgradeDialogComponent; + let fixture: ComponentFixture; + const mockDialogRef = mock(); + + 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); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 092e6b163e6..e46c534ebdd 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -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.PlanSelection); protected selectedPlan = signal(null); protected account = signal(null); + protected planSelectionStepTitleOverride = signal(null); + protected hideContinueWithoutUpgradingButton = signal(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 { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html index cdae1743cab..6106c6b08bb 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html @@ -17,7 +17,7 @@ - {{ "individualUpgradeWelcomeMessage" | i18n }} + {{ dialogTitle() | i18n }} {{ "individualUpgradeDescriptionMessage" | i18n }} @@ -59,9 +59,11 @@ {{ "individualUpgradeTaxInformationMessage" | i18n }} - - {{ "continueWithoutUpgrading" | i18n }} - + @if (!hideContinueWithoutUpgradingButton()) { + + {{ "continueWithoutUpgrading" | i18n }} + + } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 93cfa1da20f..27e69fcf0d4 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -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(); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index e9cb390d604..a9d9b959282 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -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(null); + hideContinueWithoutUpgradingButton = input(false); planSelected = output(); closeClicked = output(); 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, diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html new file mode 100644 index 00000000000..115c0be86a2 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html @@ -0,0 +1,14 @@ + + + + + + {{ "upgradeYourPlan" | i18n }} + + + diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts new file mode 100644 index 00000000000..c24e4fbdade --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -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); + }; +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts new file mode 100644 index 00000000000..abc48ff2528 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts @@ -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; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + + + + `, + }), +}; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 49f3e10c582..653a77dccdc 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -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(); const mockApiService = mock(); const mockSyncService = mock(); + const mockOrganizationService = mock(); + const mockAccountService = mock(); 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 diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index cabd148a539..11dd10d4bb8 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -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 = 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 = + 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 */ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 4230d4038cd..7b92ae10947 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -4,6 +4,22 @@ @if (isFamiliesPlan) { + @if (userIsOwnerOfFreeOrg$ | async) { + + + {{ "formWillCreateNewFamiliesOrgMessage" | i18n }} + + {{ "upgradeNow" | i18n }} + + + + + } {{ "organizationName" | i18n }} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 81a4c9191a5..33568435d01 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -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 { if (!this.isFamiliesPlan) { this.formGroup.controls.organizationName.disable(); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html index f8ebfa60451..d39156ef4a2 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html @@ -1,19 +1,25 @@ - - - - - 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) { + + + + } + + @if (shouldShowPremiumUpgradeButton$ | async) { + + } + + @let moreProducts = moreProducts$ | async; + @if (moreProducts && moreProducts.length > 0) { + {{ "moreFromBitwarden" | i18n }} @@ -57,5 +63,5 @@ - + } diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index d1b82bc114d..38e7d12f278 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -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: "Upgrade Nav Button", + 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(false); + beforeEach(async () => { productSwitcherService = mock(); 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(); + }); }); }); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts index 9d4250087af..8a02fdd7647 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts @@ -11,6 +11,9 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s export class NavigationProductSwitcherComponent { constructor(private productSwitcherService: ProductSwitcherService) {} + protected readonly shouldShowPremiumUpgradeButton$: Observable = + this.productSwitcherService.shouldShowPremiumUpgradeButton$; + protected readonly accessibleProducts$: Observable = this.productSwitcherService.products$.pipe(map((products) => products.bento ?? [])); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index b5f10f9158e..fe2821e3d2c 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -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 { } } +class MockBillingAccountProfileStateService implements Partial { + hasPremiumFromAnySource$(userId: UserId): Observable { + return of(false); + } +} + +class MockConfigService implements Partial { + getFeatureFlag$(key: Flag): Observable> { + return of(false); + } +} + @Component({ selector: "story-layout", template: ``, @@ -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, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts index b78b1ce6b96..1d0353105c6 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts @@ -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, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 18cb8e26c70..66b6a6fb3cf 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -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 { } } +class MockBillingAccountProfileStateService implements Partial { + hasPremiumFromAnySource$(userId: UserId): Observable { + return of(false); + } +} + +class MockConfigService implements Partial { + getFeatureFlag$(key: Flag): Observable> { + return of(false); + } +} + @Component({ selector: "story-layout", template: ``, @@ -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, { diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index efbeb786f77..f7f319f2fab 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -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; let accountService: FakeAccountService; let platformUtilsService: MockProxy; + let billingAccountProfileStateService: MockProxy; + let configService: MockProxy; 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(); accountService = mockAccountServiceWith(userId); platformUtilsService = mock(); + billingAccountProfileStateService = mock(); + configService = mock(); 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, + ); + }); + }); }); diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 95acf4447e9..6cfecd59403 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -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 = 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[]; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index cdb40c843a2..b507991606f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -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 { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index af0374701ca..3ba0bd95a31 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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." } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 78113d74cb8..8045a7b55f0 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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,
{{ "individualUpgradeDescriptionMessage" | i18n }} @@ -59,9 +59,11 @@
{{ "individualUpgradeTaxInformationMessage" | i18n }}