mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +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) {
|
@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) {
|
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
|
||||||
<app-upgrade-payment
|
<app-upgrade-payment
|
||||||
[selectedPlanId]="selectedPlan()"
|
[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 {Account} account - The user account information.
|
||||||
* @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any.
|
* @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 {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 = {
|
export type UnifiedUpgradeDialogParams = {
|
||||||
account: Account;
|
account: Account;
|
||||||
initialStep?: UnifiedUpgradeDialogStep | null;
|
initialStep?: UnifiedUpgradeDialogStep | null;
|
||||||
selectedPlan?: PersonalSubscriptionPricingTierId | null;
|
selectedPlan?: PersonalSubscriptionPricingTierId | null;
|
||||||
|
planSelectionStepTitleOverride?: string | null;
|
||||||
|
hideContinueWithoutUpgradingButton?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -73,6 +77,8 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
|||||||
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
|
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
|
||||||
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
|
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
|
||||||
protected account = signal<Account | 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 PaymentStep = UnifiedUpgradeDialogStep.Payment;
|
||||||
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
|
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
|
||||||
@@ -86,6 +92,10 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
|||||||
this.account.set(this.params.account);
|
this.account.set(this.params.account);
|
||||||
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
|
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
|
||||||
this.selectedPlan.set(this.params.selectedPlan ?? null);
|
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 {
|
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="tw-px-14 tw-pb-8">
|
<div class="tw-px-14 tw-pb-8">
|
||||||
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
|
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
|
||||||
<h1 class="tw-font-semibold tw-text-[32px]">
|
<h1 class="tw-font-semibold tw-text-[32px]">
|
||||||
{{ "individualUpgradeWelcomeMessage" | i18n }}
|
{{ dialogTitle() | i18n }}
|
||||||
</h1>
|
</h1>
|
||||||
<p bitTypography="body1" class="tw-text-muted">
|
<p bitTypography="body1" class="tw-text-muted">
|
||||||
{{ "individualUpgradeDescriptionMessage" | i18n }}
|
{{ "individualUpgradeDescriptionMessage" | i18n }}
|
||||||
@@ -59,9 +59,11 @@
|
|||||||
<p bitTypography="helper" class="tw-text-muted tw-italic">
|
<p bitTypography="helper" class="tw-text-muted tw-italic">
|
||||||
{{ "individualUpgradeTaxInformationMessage" | i18n }}
|
{{ "individualUpgradeTaxInformationMessage" | i18n }}
|
||||||
</p>
|
</p>
|
||||||
|
@if (!hideContinueWithoutUpgradingButton()) {
|
||||||
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
|
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
|
||||||
{{ "continueWithoutUpgrading" | i18n }}
|
{{ "continueWithoutUpgrading" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -146,4 +146,46 @@ describe("UpgradeAccountComponent", () => {
|
|||||||
expect(result).toBe(false);
|
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 { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||||
import { CommonModule } from "@angular/common";
|
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 { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@@ -52,6 +52,8 @@ type CardDetails = {
|
|||||||
templateUrl: "./upgrade-account.component.html",
|
templateUrl: "./upgrade-account.component.html",
|
||||||
})
|
})
|
||||||
export class UpgradeAccountComponent implements OnInit {
|
export class UpgradeAccountComponent implements OnInit {
|
||||||
|
dialogTitleMessageOverride = input<string | null>(null);
|
||||||
|
hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||||
closeClicked = output<UpgradeAccountStatus>();
|
closeClicked = output<UpgradeAccountStatus>();
|
||||||
protected loading = signal(true);
|
protected loading = signal(true);
|
||||||
@@ -62,6 +64,10 @@ export class UpgradeAccountComponent implements OnInit {
|
|||||||
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
|
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
|
||||||
protected closeStatus = UpgradeAccountStatus.Closed;
|
protected closeStatus = UpgradeAccountStatus.Closed;
|
||||||
|
|
||||||
|
protected dialogTitle = computed(() => {
|
||||||
|
return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage";
|
||||||
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private subscriptionPricingService: SubscriptionPricingService,
|
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 { TestBed } from "@angular/core/testing";
|
||||||
import { mock, mockReset } from "jest-mock-extended";
|
import { mock, mockReset } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
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 { 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 { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
@@ -22,6 +28,8 @@ describe("UpgradePaymentService", () => {
|
|||||||
const mockLogService = mock<LogService>();
|
const mockLogService = mock<LogService>();
|
||||||
const mockApiService = mock<ApiService>();
|
const mockApiService = mock<ApiService>();
|
||||||
const mockSyncService = mock<SyncService>();
|
const mockSyncService = mock<SyncService>();
|
||||||
|
const mockOrganizationService = mock<OrganizationService>();
|
||||||
|
const mockAccountService = mock<AccountService>();
|
||||||
|
|
||||||
mockApiService.refreshIdentityToken.mockResolvedValue({});
|
mockApiService.refreshIdentityToken.mockResolvedValue({});
|
||||||
mockSyncService.fullSync.mockResolvedValue(true);
|
mockSyncService.fullSync.mockResolvedValue(true);
|
||||||
@@ -94,6 +102,11 @@ describe("UpgradePaymentService", () => {
|
|||||||
mockReset(mockAccountBillingClient);
|
mockReset(mockAccountBillingClient);
|
||||||
mockReset(mockTaxClient);
|
mockReset(mockTaxClient);
|
||||||
mockReset(mockLogService);
|
mockReset(mockLogService);
|
||||||
|
mockReset(mockOrganizationService);
|
||||||
|
mockReset(mockAccountService);
|
||||||
|
|
||||||
|
mockAccountService.activeAccount$ = of(null);
|
||||||
|
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -108,12 +121,204 @@ describe("UpgradePaymentService", () => {
|
|||||||
{ provide: LogService, useValue: mockLogService },
|
{ provide: LogService, useValue: mockLogService },
|
||||||
{ provide: ApiService, useValue: mockApiService },
|
{ provide: ApiService, useValue: mockApiService },
|
||||||
{ provide: SyncService, useValue: mockSyncService },
|
{ provide: SyncService, useValue: mockSyncService },
|
||||||
|
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||||
|
{ provide: AccountService, useValue: mockAccountService },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
sut = TestBed.inject(UpgradePaymentService);
|
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", () => {
|
describe("calculateEstimatedTax", () => {
|
||||||
it("should calculate tax for premium plan", async () => {
|
it("should calculate tax for premium plan", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
|
import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
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 { 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 {
|
import {
|
||||||
OrganizationBillingServiceAbstraction,
|
OrganizationBillingServiceAbstraction,
|
||||||
SubscriptionInformation,
|
SubscriptionInformation,
|
||||||
@@ -53,8 +57,28 @@ export class UpgradePaymentService {
|
|||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private syncService: SyncService,
|
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
|
* Calculate estimated tax for the selected plan
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
<ng-container bitDialogContent>
|
<ng-container bitDialogContent>
|
||||||
<section>
|
<section>
|
||||||
@if (isFamiliesPlan) {
|
@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">
|
<div class="tw-pb-4">
|
||||||
<bit-form-field class="!tw-mb-0">
|
<bit-form-field class="!tw-mb-0">
|
||||||
<bit-label>{{ "organizationName" | i18n }}</bit-label>
|
<bit-label>{{ "organizationName" | i18n }}</bit-label>
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
|||||||
private upgradePaymentService: UpgradePaymentService,
|
private upgradePaymentService: UpgradePaymentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
protected userIsOwnerOfFreeOrg$ = this.upgradePaymentService.userIsOwnerOfFreeOrg$;
|
||||||
|
protected adminConsoleRouteForOwnedOrganization$ =
|
||||||
|
this.upgradePaymentService.adminConsoleRouteForOwnedOrganization$;
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
if (!this.isFamiliesPlan) {
|
if (!this.isFamiliesPlan) {
|
||||||
this.formGroup.controls.organizationName.disable();
|
this.formGroup.controls.organizationName.disable();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<div class="tw-mt-auto">
|
<div class="tw-mt-auto">
|
||||||
|
@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. -->
|
<!-- [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
|
<bit-nav-item
|
||||||
*ngFor="let product of accessibleProducts$ | async"
|
*ngFor="let product of accessibleProducts"
|
||||||
[icon]="product.icon"
|
[icon]="product.icon"
|
||||||
[text]="product.name"
|
[text]="product.name"
|
||||||
[route]="product.appRoute"
|
[route]="product.appRoute"
|
||||||
@@ -9,11 +11,15 @@
|
|||||||
[forceActiveStyles]="product.isActive"
|
[forceActiveStyles]="product.isActive"
|
||||||
>
|
>
|
||||||
</bit-nav-item>
|
</bit-nav-item>
|
||||||
<ng-container *ngIf="moreProducts$ | async as moreProducts">
|
}
|
||||||
<section
|
|
||||||
*ngIf="moreProducts.length > 0"
|
@if (shouldShowPremiumUpgradeButton$ | async) {
|
||||||
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0"
|
<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>
|
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
|
||||||
<ng-container *ngFor="let more of moreProducts">
|
<ng-container *ngFor="let more of moreProducts">
|
||||||
<div class="tw-ps-2 tw-pe-2">
|
<div class="tw-ps-2 tw-pe-2">
|
||||||
@@ -57,5 +63,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</section>
|
</section>
|
||||||
</ng-container>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||||
@@ -15,6 +16,13 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
|
|||||||
|
|
||||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
|
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", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: jest.fn().mockImplementation((query) => ({
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
@@ -41,13 +49,16 @@ describe("NavigationProductSwitcherComponent", () => {
|
|||||||
other: [],
|
other: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockShouldShowPremiumUpgradeButton$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
productSwitcherService = mock<ProductSwitcherService>();
|
productSwitcherService = mock<ProductSwitcherService>();
|
||||||
productSwitcherService.products$ = mockProducts$;
|
productSwitcherService.products$ = mockProducts$;
|
||||||
|
productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$;
|
||||||
mockProducts$.next({ bento: [], other: [] });
|
mockProducts$.next({ bento: [], other: [] });
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [RouterModule, NavigationModule, IconButtonModule],
|
imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent],
|
||||||
declarations: [NavigationProductSwitcherComponent, I18nPipe],
|
declarations: [NavigationProductSwitcherComponent, I18nPipe],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: ProductSwitcherService, useValue: productSwitcherService },
|
{ provide: ProductSwitcherService, useValue: productSwitcherService },
|
||||||
@@ -187,15 +198,23 @@ describe("NavigationProductSwitcherComponent", () => {
|
|||||||
},
|
},
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Test Product",
|
||||||
|
icon: "bwi-lock",
|
||||||
|
marketingRoute: {
|
||||||
|
route: "https://www.example.com/",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
other: [],
|
other: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
fixture.detectChanges();
|
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[0].textContent).toContain("Password Manager");
|
||||||
expect(links[1].textContent).toContain("Secret Manager");
|
expect(links[1].textContent).toContain("Secret Manager");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("links to `appRoute`", () => {
|
it("does not show products list when there is only one item", () => {
|
||||||
mockProducts$.next({
|
mockProducts$.next({
|
||||||
bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
|
bento: [{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
|
||||||
other: [],
|
other: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const link = fixture.nativeElement.querySelector("a");
|
const navItems = fixture.debugElement.queryAll(By.directive(NavItemComponent));
|
||||||
|
|
||||||
expect(link.getAttribute("href")).toBe("/vault");
|
expect(navItems.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links to `appRoute`", () => {
|
||||||
|
mockProducts$.next({
|
||||||
|
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 links = fixture.nativeElement.querySelectorAll("a");
|
||||||
|
|
||||||
|
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 {
|
export class NavigationProductSwitcherComponent {
|
||||||
constructor(private productSwitcherService: ProductSwitcherService) {}
|
constructor(private productSwitcherService: ProductSwitcherService) {}
|
||||||
|
|
||||||
|
protected readonly shouldShowPremiumUpgradeButton$: Observable<boolean> =
|
||||||
|
this.productSwitcherService.shouldShowPremiumUpgradeButton$;
|
||||||
|
|
||||||
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
|
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
|
||||||
this.productSwitcherService.products$.pipe(map((products) => products.bento ?? []));
|
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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { 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({
|
@Component({
|
||||||
selector: "story-layout",
|
selector: "story-layout",
|
||||||
template: `<ng-content></ng-content>`,
|
template: `<ng-content></ng-content>`,
|
||||||
@@ -117,6 +132,11 @@ export default {
|
|||||||
{ provide: ProviderService, useClass: MockProviderService },
|
{ provide: ProviderService, useClass: MockProviderService },
|
||||||
{ provide: SyncService, useClass: MockSyncService },
|
{ provide: SyncService, useClass: MockSyncService },
|
||||||
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
||||||
|
{
|
||||||
|
provide: BillingAccountProfileStateService,
|
||||||
|
useClass: MockBillingAccountProfileStateService,
|
||||||
|
},
|
||||||
|
{ provide: ConfigService, useClass: MockConfigService },
|
||||||
ProductSwitcherService,
|
ProductSwitcherService,
|
||||||
{
|
{
|
||||||
provide: I18nService,
|
provide: I18nService,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RouterModule } from "@angular/router";
|
|||||||
import { NavigationModule } from "@bitwarden/components";
|
import { NavigationModule } from "@bitwarden/components";
|
||||||
import { I18nPipe } from "@bitwarden/ui-common";
|
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 { SharedModule } from "../../shared";
|
||||||
|
|
||||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component";
|
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";
|
import { ProductSwitcherComponent } from "./product-switcher.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [SharedModule, A11yModule, RouterModule, NavigationModule, I18nPipe],
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
A11yModule,
|
||||||
|
RouterModule,
|
||||||
|
NavigationModule,
|
||||||
|
I18nPipe,
|
||||||
|
UpgradeNavButtonComponent,
|
||||||
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
ProductSwitcherComponent,
|
ProductSwitcherComponent,
|
||||||
ProductSwitcherContentComponent,
|
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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { 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({
|
@Component({
|
||||||
selector: "story-layout",
|
selector: "story-layout",
|
||||||
template: `<ng-content></ng-content>`,
|
template: `<ng-content></ng-content>`,
|
||||||
@@ -114,6 +129,11 @@ export default {
|
|||||||
MockProviderService,
|
MockProviderService,
|
||||||
{ provide: SyncService, useClass: MockSyncService },
|
{ provide: SyncService, useClass: MockSyncService },
|
||||||
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
||||||
|
{
|
||||||
|
provide: BillingAccountProfileStateService,
|
||||||
|
useClass: MockBillingAccountProfileStateService,
|
||||||
|
},
|
||||||
|
{ provide: ConfigService, useClass: MockConfigService },
|
||||||
MockPlatformUtilsService,
|
MockPlatformUtilsService,
|
||||||
ProductSwitcherService,
|
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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
@@ -27,6 +29,8 @@ describe("ProductSwitcherService", () => {
|
|||||||
let providerService: MockProxy<ProviderService>;
|
let providerService: MockProxy<ProviderService>;
|
||||||
let accountService: FakeAccountService;
|
let accountService: FakeAccountService;
|
||||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||||
|
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||||
|
let configService: MockProxy<ConfigService>;
|
||||||
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
|
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
|
||||||
let singleOrgPolicyEnabled = false;
|
let singleOrgPolicyEnabled = false;
|
||||||
const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14"));
|
const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14"));
|
||||||
@@ -48,6 +52,8 @@ describe("ProductSwitcherService", () => {
|
|||||||
providerService = mock<ProviderService>();
|
providerService = mock<ProviderService>();
|
||||||
accountService = mockAccountServiceWith(userId);
|
accountService = mockAccountServiceWith(userId);
|
||||||
platformUtilsService = mock<PlatformUtilsService>();
|
platformUtilsService = mock<PlatformUtilsService>();
|
||||||
|
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||||
|
configService = mock<ConfigService>();
|
||||||
|
|
||||||
router.url = "/";
|
router.url = "/";
|
||||||
router.events = of({});
|
router.events = of({});
|
||||||
@@ -85,6 +91,8 @@ describe("ProductSwitcherService", () => {
|
|||||||
policyAppliesToUser$: () => of(singleOrgPolicyEnabled),
|
policyAppliesToUser$: () => of(singleOrgPolicyEnabled),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
|
||||||
|
{ provide: ConfigService, useValue: configService },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -325,4 +333,57 @@ describe("ProductSwitcherService", () => {
|
|||||||
|
|
||||||
expect(appRoute).toEqual(["/organizations", "111-22-33"]);
|
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
|
// @ts-strict-ignore
|
||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router";
|
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 {
|
import {
|
||||||
canAccessOrgAdmin,
|
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 { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
@@ -99,6 +111,8 @@ export class ProductSwitcherService {
|
|||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private policyService: PolicyService,
|
private policyService: PolicyService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
|
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.pollUntilSynced();
|
this.pollUntilSynced();
|
||||||
}
|
}
|
||||||
@@ -118,6 +132,20 @@ export class ProductSwitcherService {
|
|||||||
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
|
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<{
|
products$: Observable<{
|
||||||
bento: ProductSwitcherItem[];
|
bento: ProductSwitcherItem[];
|
||||||
other: ProductSwitcherItem[];
|
other: ProductSwitcherItem[];
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
DefaultCipherFormConfigService,
|
DefaultCipherFormConfigService,
|
||||||
PasswordRepromptService,
|
PasswordRepromptService,
|
||||||
} from "@bitwarden/vault";
|
} 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 { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||||
|
|
||||||
@@ -103,7 +104,6 @@ import {
|
|||||||
CollectionDialogTabType,
|
CollectionDialogTabType,
|
||||||
openCollectionDialog,
|
openCollectionDialog,
|
||||||
} from "../../admin-console/organizations/shared/components/collection-dialog";
|
} 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 { SharedModule } from "../../shared/shared.module";
|
||||||
import { AssignCollectionsWebComponent } from "../components/assign-collections";
|
import { AssignCollectionsWebComponent } from "../components/assign-collections";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -11805,6 +11805,15 @@
|
|||||||
"continueWithoutUpgrading": {
|
"continueWithoutUpgrading": {
|
||||||
"message": "Continue without upgrading"
|
"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": {
|
"upgradeErrorMessage": {
|
||||||
"message": "We encountered an error while processing your upgrade. Please try again."
|
"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",
|
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||||
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||||
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
|
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",
|
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||||
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||||
|
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user