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:
@@ -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()"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? []));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user