1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 19:34:03 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Conner Turnbull
2026-02-09 15:12:43 -05:00
committed by GitHub
39 changed files with 3748 additions and 248 deletions

View File

@@ -19,12 +19,14 @@
@if (p.display$(organization, configService) | async) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p, organizationId)">
{{ p.name | i18n }}
</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
<div class="tw-flex tw-items-center tw-gap-2">
<button type="button" bitLink (click)="edit(p, organizationId)">
{{ p.name | i18n }}
</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
</div>
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>

View File

@@ -13,29 +13,24 @@
<bit-label>{{ "enforceOnLoginDesc" | i18n }}</bit-label>
</bit-form-control>
<div class="tw-flex tw-space-x-4">
<bit-form-field class="tw-flex-auto">
<bit-label>{{ "minComplexityScore" | i18n }}</bit-label>
<bit-select formControlName="minComplexity" id="minComplexity">
<bit-option
*ngFor="let o of passwordScores"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field class="tw-flex-auto">
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input
bitInput
type="number"
formControlName="minLength"
id="minLength"
[min]="MinPasswordLength"
[max]="MaxPasswordLength"
/>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "minComplexityScore" | i18n }}</bit-label>
<bit-select formControlName="minComplexity" id="minComplexity">
<bit-option *ngFor="let o of passwordScores" [value]="o.value" [label]="o.name"></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input
bitInput
type="number"
formControlName="minLength"
id="minLength"
[min]="MinPasswordLength"
[max]="MaxPasswordLength"
/>
</bit-form-field>
<bit-form-control class="!tw-mb-2">
<input type="checkbox" bitCheckbox formControlName="requireUpper" id="requireUpper" />

View File

@@ -4,56 +4,50 @@
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6 tw-mb-0">
<bit-label>{{ "overridePasswordTypePolicy" | i18n }}</bit-label>
<bit-select formControlName="overridePasswordType" id="overrideType">
<bit-option
*ngFor="let o of overridePasswordTypeOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "passwordTypePolicyOverride" | i18n }}</bit-label>
<bit-select formControlName="overridePasswordType" id="overrideType">
<bit-option
*ngFor="let o of overridePasswordTypeOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
</bit-form-field>
<!-- password-specific policies -->
<div *ngIf="showPasswordPolicies$ | async">
<h3 bitTypography="h3" class="tw-mt-4">{{ "password" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minLengthMin"
[max]="minLengthMax"
formControlName="minLength"
/>
</bit-form-field>
</div>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minNumbersMin"
[max]="minNumbersMax"
formControlName="minNumbers"
/>
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minSpecialMin"
[max]="minSpecialMax"
formControlName="minSpecial"
/>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minLengthMin"
[max]="minLengthMax"
formControlName="minLength"
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minNumbersMin"
[max]="minNumbersMax"
formControlName="minNumbers"
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minSpecialMin"
[max]="minSpecialMax"
formControlName="minSpecial"
/>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useUpper" id="useUpper" />
<bit-label>{{ "uppercaseLabel" | i18n }}</bit-label>
@@ -79,18 +73,16 @@
<!-- passphrase-specific policies -->
<div *ngIf="showPassphrasePolicies$ | async">
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minNumberWordsMin"
[max]="minNumberWordsMax"
formControlName="minNumberWords"
/>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
<input
bitInput
type="number"
[min]="minNumberWordsMin"
[max]="minNumberWordsMax"
formControlName="minNumberWords"
/>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="capitalize" id="capitalize" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>

View File

@@ -1,5 +1,13 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
<bit-dialog dialogSize="default" [loading]="loading">
<ng-container bitDialogTitle>
<span class="tw-flex tw-items-center tw-gap-2">
{{ policy.name | i18n }}
@if (isPolicyEnabled) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
</span>
</ng-container>
<ng-container bitDialogContent>
<div *ngIf="loading">
<i

View File

@@ -81,6 +81,10 @@ export class PolicyEditDialogComponent implements AfterViewInit {
return this.data.policy;
}
get isPolicyEnabled(): boolean {
return this.policyComponent?.policyResponse?.enabled ?? false;
}
/**
* Type guard to check if the policy component has the buildVNextRequest method.
*/
@@ -196,6 +200,9 @@ export class PolicyEditDialogComponent implements AfterViewInit {
);
}
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(PolicyEditDialogComponent, config);
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
PolicyEditDialogComponent,
config,
);
};
}

View File

@@ -1,5 +1,5 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
<bit-dialog dialogSize="large" [loading]="loading">
<ng-container bitDialogTitle>
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
@if (title) {
@@ -40,13 +40,16 @@
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-[99px] tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span>
<span class="tw-flex tw-items-center tw-gap-2">
{{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }}
@if (!showBadge) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy.name | i18n }}
</span>
}
@if (isPolicyEnabled) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
</span>
</div>
</ng-template>

View File

@@ -287,6 +287,9 @@ export class AutoConfirmPolicyDialogComponent
dialogService: DialogService,
config: DialogConfig<AutoConfirmPolicyDialogData>,
) => {
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
return dialogService.openDrawer<PolicyEditDialogResult, AutoConfirmPolicyDialogData>(
AutoConfirmPolicyDialogComponent,
config,
);
};
}

View File

@@ -1,5 +1,5 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
<bit-dialog dialogSize="large" [loading]="loading">
<ng-container bitDialogTitle>
@let title = multiStepSubmit()[currentStep()]?.titleContent();
@if (title) {
@@ -35,7 +35,12 @@
</form>
<ng-template #step0Title>
{{ policy.name | i18n }}
<span class="tw-flex tw-items-center tw-gap-2">
{{ policy.name | i18n }}
@if (isPolicyEnabled) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
</span>
</ng-template>
<ng-template #step1Title>

View File

@@ -216,7 +216,7 @@ export class OrganizationDataOwnershipPolicyDialogComponent
};
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
OrganizationDataOwnershipPolicyDialogComponent,
config,
);

View File

@@ -25,7 +25,7 @@
<billing-subscription-card
[title]="'premiumMembership' | i18n"
[subscription]="subscription"
[showUpgradeButton]="premiumToOrganizationUpgradeEnabled()"
[showUpgradeButton]="canUpgradeFromPremium()"
(callToActionClicked)="onSubscriptionCardAction($event)"
/>

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, lastValueFrom, map } from "rxjs";
import { firstValueFrom, lastValueFrom, map, switchMap, of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -39,6 +39,11 @@ import {
openOffboardingSurvey,
} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component";
import {
PremiumOrgUpgradeDialogComponent,
PremiumOrgUpgradeDialogParams,
} from "../upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component";
@Component({
templateUrl: "./account-subscription.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -65,20 +70,30 @@ export class AccountSubscriptionComponent {
private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction);
private toastService = inject(ToastService);
readonly account = toSignal(this.accountService.activeAccount$);
readonly hasPremiumPersonally = toSignal(
this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (!account) {
return of(false);
}
return this.billingAccountProfileStateService.hasPremiumPersonally$(account.id);
}),
),
{ initialValue: false },
);
readonly subscription = resource({
loader: async () => {
const redirectToPremiumPage = async (): Promise<null> => {
await this.router.navigate(["/settings/subscription/premium"]);
return null;
};
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
if (!this.account()) {
return await redirectToPremiumPage();
}
const hasPremiumPersonally = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
);
if (!hasPremiumPersonally) {
if (!this.hasPremiumPersonally()) {
return await redirectToPremiumPage();
}
return await this.accountBillingClient.getSubscription();
@@ -177,6 +192,13 @@ export class AccountSubscriptionComponent {
{ initialValue: false },
);
readonly canUpgradeFromPremium = computed<boolean>(() => {
// Since account is checked in hasPremiumPersonally, no need to check again here
const hasPremiumPersonally = this.hasPremiumPersonally();
const upgradeEnabled = this.premiumToOrganizationUpgradeEnabled();
return hasPremiumPersonally && upgradeEnabled;
});
onSubscriptionCardAction = async (action: SubscriptionCardAction) => {
switch (action) {
case SubscriptionCardActions.ContactSupport:
@@ -209,7 +231,7 @@ export class AccountSubscriptionComponent {
await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute });
break;
case SubscriptionCardActions.UpgradePlan:
// TODO: Implement upgrade plan navigation
await this.openUpgradeDialog();
break;
}
};
@@ -288,4 +310,21 @@ export class AccountSubscriptionComponent {
}
}
};
openUpgradeDialog = async (): Promise<void> => {
const account = this.account();
if (!account) {
return;
}
const dialogParams: PremiumOrgUpgradeDialogParams = {
account,
redirectOnCompletion: true,
};
const dialogRef = PremiumOrgUpgradeDialogComponent.open(this.dialogService, {
data: dialogParams,
});
await firstValueFrom(dialogRef.closed);
};
}

View File

@@ -0,0 +1,13 @@
@if (step() == PlanSelectionStep) {
<app-premium-org-upgrade-plan-selection
(planSelected)="onPlanSelected($event)"
(closeClicked)="onCloseClicked()"
/>
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
<app-premium-org-upgrade-payment
[selectedPlanId]="selectedPlan()"
[account]="account()"
(goBack)="previousStep()"
(complete)="onComplete($event)"
/>
}

View File

@@ -0,0 +1,464 @@
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import {
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTierId,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
PremiumOrgUpgradePaymentComponent,
PremiumOrgUpgradePaymentResult,
PremiumOrgUpgradePaymentStatus,
} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component";
import { PremiumOrgUpgradePlanSelectionComponent } from "../premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component";
import {
PremiumOrgUpgradeDialogComponent,
PremiumOrgUpgradeDialogParams,
PremiumOrgUpgradeDialogStep,
} from "./premium-org-upgrade-dialog.component";
@Component({
selector: "app-premium-org-upgrade-plan-selection",
template: "",
standalone: true,
providers: [PremiumOrgUpgradePlanSelectionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPremiumOrgUpgradePlanSelectionComponent {
readonly dialogTitleMessageOverride = input<string | null>(null);
readonly hideContinueWithoutUpgradingButton = input<boolean>(false);
planSelected = output<BusinessSubscriptionPricingTierId>();
closeClicked = output<PremiumOrgUpgradePaymentStatus>();
}
@Component({
selector: "app-premium-org-upgrade-payment",
template: "",
standalone: true,
providers: [PremiumOrgUpgradePaymentComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPremiumOrgUpgradePaymentComponent {
readonly selectedPlanId = input<
BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null
>(null);
readonly account = input<Account | null>(null);
goBack = output<void>();
complete = output<{ status: PremiumOrgUpgradePaymentStatus; organizationId: string | null }>();
}
describe("PremiumOrgUpgradeDialogComponent", () => {
let component: PremiumOrgUpgradeDialogComponent;
let fixture: ComponentFixture<PremiumOrgUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockRouter = mock<Router>();
const mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const mockConfigService = mock<ConfigService>();
const mockAccount: Account = {
id: "user-id" as UserId,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const defaultDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
initialStep: null,
selectedPlan: null,
};
/**
* Helper function to create and configure a fresh component instance with custom dialog data
*/
async function createComponentWithDialogData(
dialogData: PremiumOrgUpgradeDialogParams,
waitForStable = false,
): Promise<{
fixture: ComponentFixture<PremiumOrgUpgradeDialogComponent>;
component: PremiumOrgUpgradeDialogComponent;
}> {
TestBed.resetTestingModule();
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, PremiumOrgUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: dialogData },
{ provide: Router, useValue: mockRouter },
{
provide: BillingAccountProfileStateService,
useValue: mockBillingAccountProfileStateService,
},
{ provide: ConfigService, useValue: mockConfigService },
],
})
.overrideComponent(PremiumOrgUpgradeDialogComponent, {
remove: {
imports: [PremiumOrgUpgradePlanSelectionComponent, PremiumOrgUpgradePaymentComponent],
},
add: {
imports: [
MockPremiumOrgUpgradePlanSelectionComponent,
MockPremiumOrgUpgradePaymentComponent,
],
},
})
.compileComponents();
const newFixture = TestBed.createComponent(PremiumOrgUpgradeDialogComponent);
const newComponent = newFixture.componentInstance;
newFixture.detectChanges();
if (waitForStable) {
await newFixture.whenStable();
}
return { fixture: newFixture, component: newComponent };
}
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
await TestBed.configureTestingModule({
imports: [PremiumOrgUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
{ provide: Router, useValue: mockRouter },
{
provide: BillingAccountProfileStateService,
useValue: mockBillingAccountProfileStateService,
},
{ provide: ConfigService, useValue: mockConfigService },
],
})
.overrideComponent(PremiumOrgUpgradeDialogComponent, {
remove: {
imports: [PremiumOrgUpgradePlanSelectionComponent, PremiumOrgUpgradePaymentComponent],
},
add: {
imports: [
MockPremiumOrgUpgradePlanSelectionComponent,
MockPremiumOrgUpgradePaymentComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(PremiumOrgUpgradeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize with default values", () => {
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
expect(component["account"]()).toEqual(mockAccount);
});
it("should initialize with custom initial step", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
initialStep: PremiumOrgUpgradeDialogStep.Payment,
selectedPlan: "teams" as BusinessSubscriptionPricingTierId,
};
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
expect(customComponent["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment);
expect(customComponent["selectedPlan"]()).toBe("teams");
});
describe("onPlanSelected", () => {
it("should set selected plan and move to payment step", () => {
component["onPlanSelected"]("teams" as BusinessSubscriptionPricingTierId);
expect(component["selectedPlan"]()).toBe("teams");
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment);
});
it("should handle selecting Enterprise plan", () => {
component["onPlanSelected"]("enterprise" as BusinessSubscriptionPricingTierId);
expect(component["selectedPlan"]()).toBe("enterprise");
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment);
});
});
describe("previousStep", () => {
it("should go back to plan selection and clear selected plan", async () => {
component["step"].set(PremiumOrgUpgradeDialogStep.Payment);
component["selectedPlan"].set("teams" as BusinessSubscriptionPricingTierId);
await component["previousStep"]();
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
});
it("should close dialog when backing out from initial step", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
initialStep: PremiumOrgUpgradeDialogStep.Payment,
selectedPlan: "teams" as BusinessSubscriptionPricingTierId,
};
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
await customComponent["previousStep"]();
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("onComplete", () => {
it("should handle completing upgrade to Families successfully", async () => {
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
mockRouter.navigate.mockResolvedValue(true);
const result = {
status: "upgradedToFamilies" as const,
organizationId: "org-111",
};
await testComponent["onComplete"](result);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-111",
});
});
it("should handle completing upgrade to Teams successfully", async () => {
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
mockRouter.navigate.mockResolvedValue(true);
const result = {
status: "upgradedToTeams" as const,
organizationId: "org-123",
};
await testComponent["onComplete"](result);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToTeams",
organizationId: "org-123",
});
});
it("should handle completing upgrade to Enterprise successfully", async () => {
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
mockRouter.navigate.mockResolvedValue(true);
const result = {
status: "upgradedToEnterprise" as const,
organizationId: "org-456",
};
await testComponent["onComplete"](result);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToEnterprise",
organizationId: "org-456",
});
});
it("should redirect to organization vault after Teams upgrade when redirectOnCompletion is true", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockRouter.navigate.mockResolvedValue(true);
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
const result = {
status: "upgradedToTeams" as const,
organizationId: "org-123",
};
await customComponent["onComplete"](result);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-123/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToTeams",
organizationId: "org-123",
});
});
it("should redirect to organization vault after Enterprise upgrade when redirectOnCompletion is true", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockRouter.navigate.mockResolvedValue(true);
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
const result = {
status: "upgradedToEnterprise" as const,
organizationId: "org-789",
};
await customComponent["onComplete"](result);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-789/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToEnterprise",
organizationId: "org-789",
});
});
it("should redirect to organization vault after Families upgrade when redirectOnCompletion is true", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockRouter.navigate.mockResolvedValue(true);
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
const result = {
status: "upgradedToFamilies" as const,
organizationId: "org-999",
};
await customComponent["onComplete"](result);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-999/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-999",
});
});
it("should not redirect when redirectOnCompletion is false", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: false,
};
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
const result = {
status: "upgradedToTeams" as const,
organizationId: "org-123",
};
await customComponent["onComplete"](result);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToTeams",
organizationId: "org-123",
});
});
it("should handle closed status", async () => {
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
const result: PremiumOrgUpgradePaymentResult = { status: "closed", organizationId: null };
await testComponent["onComplete"](result);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "closed",
organizationId: null,
});
});
});
describe("onCloseClicked", () => {
it("should close dialog", async () => {
await component["onCloseClicked"]();
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("Premium and Feature Flag Requirements", () => {
it("should close dialog immediately if user does not have premium", async () => {
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(false));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
await createComponentWithDialogData(defaultDialogData, true);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
it("should close dialog immediately if feature flag is not enabled", async () => {
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
await createComponentWithDialogData(defaultDialogData, true);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
it("should close dialog immediately if user does not have premium and feature flag is not enabled", async () => {
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(false));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
await createComponentWithDialogData(defaultDialogData, true);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("Child Component Display Logic", () => {
describe("Plan Selection Step", () => {
it("should display app-premium-org-upgrade-plan-selection on plan selection step", async () => {
const { fixture } = await createComponentWithDialogData(defaultDialogData);
const premiumOrgUpgradeElement = fixture.nativeElement.querySelector(
"app-premium-org-upgrade-plan-selection",
);
expect(premiumOrgUpgradeElement).toBeTruthy();
});
});
describe("Payment Step", () => {
it("should display app-premium-org-upgrade-payment on payment step", async () => {
const customDialogData: PremiumOrgUpgradeDialogParams = {
account: mockAccount,
initialStep: PremiumOrgUpgradeDialogStep.Payment,
selectedPlan: "teams" as BusinessSubscriptionPricingTierId,
};
const { fixture } = await createComponentWithDialogData(customDialogData);
const premiumOrgUpgradePaymentElement = fixture.nativeElement.querySelector(
"app-premium-org-upgrade-payment",
);
expect(premiumOrgUpgradePaymentElement).toBeTruthy();
});
});
});
});

View File

@@ -0,0 +1,213 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
Inject,
OnInit,
signal,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import {
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTierId,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
ButtonModule,
DialogConfig,
DialogModule,
DialogRef,
DialogService,
} from "@bitwarden/components";
import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients";
import { BillingServicesModule } from "../../../services";
import {
PremiumOrgUpgradePaymentComponent,
PremiumOrgUpgradePaymentResult,
} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component";
import { PremiumOrgUpgradePlanSelectionComponent } from "../premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component";
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
export const PremiumOrgUpgradeDialogStatus = {
Closed: "closed",
UpgradedToFamilies: "upgradedToFamilies",
UpgradedToTeams: "upgradedToTeams",
UpgradedToEnterprise: "upgradedToEnterprise",
} as const;
export const PremiumOrgUpgradeDialogStep = {
PlanSelection: "planSelection",
Payment: "payment",
} as const;
export type PremiumOrgUpgradeDialogStatus = UnionOfValues<typeof PremiumOrgUpgradeDialogStatus>;
export type PremiumOrgUpgradeDialogStep = UnionOfValues<typeof PremiumOrgUpgradeDialogStep>;
export type PremiumOrgUpgradeDialogResult = {
status: PremiumOrgUpgradeDialogStatus;
organizationId?: string | null;
};
/**
* Parameters for the PremiumOrgUpgradeDialog component.
* In order to open the dialog to a specific step, you must provide the `initialStep` parameter and a `selectedPlan` if the step is `Payment`.
*
* @property {Account} account - The user account information.
* @property {PremiumOrgUpgradeDialogStep | null} [initialStep] - The initial step to open the dialog to, if any.
* @property {BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
* @property {boolean} [redirectOnCompletion] - Whether to redirect after successful upgrade to organization vault.
*/
export type PremiumOrgUpgradeDialogParams = {
account: Account;
initialStep?: PremiumOrgUpgradeDialogStep | null;
selectedPlan?: BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null;
redirectOnCompletion?: boolean;
};
@Component({
selector: "app-premium-org-upgrade-dialog",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DialogModule,
ButtonModule,
BillingServicesModule,
PremiumOrgUpgradePlanSelectionComponent,
PremiumOrgUpgradePaymentComponent,
],
providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient],
templateUrl: "./premium-org-upgrade-dialog.component.html",
})
export class PremiumOrgUpgradeDialogComponent implements OnInit {
// Use signals for dialog state because inputs depend on parent component
protected readonly step = signal<PremiumOrgUpgradeDialogStep>(
PremiumOrgUpgradeDialogStep.PlanSelection,
);
protected readonly selectedPlan = signal<
BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null
>(null);
protected readonly account = signal<Account | null>(null);
protected readonly hasPremiumPersonally = toSignal(
this.billingAccountProfileStateService.hasPremiumPersonally$(this.params.account.id),
{ initialValue: false },
);
protected readonly premiumToOrganizationUpgradeEnabled = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade),
{ initialValue: false },
);
protected readonly showPremiumToOrganizationUpgrade = computed(
() => this.hasPremiumPersonally() && this.premiumToOrganizationUpgradeEnabled(),
);
protected readonly PaymentStep = PremiumOrgUpgradeDialogStep.Payment;
protected readonly PlanSelectionStep = PremiumOrgUpgradeDialogStep.PlanSelection;
constructor(
private dialogRef: DialogRef<PremiumOrgUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: PremiumOrgUpgradeDialogParams,
private router: Router,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
if (!this.showPremiumToOrganizationUpgrade()) {
// If the premium to organization upgrade feature is not enabled or user does not have premium personally, close the dialog
this.close({ status: PremiumOrgUpgradeDialogStatus.Closed });
return;
}
this.account.set(this.params.account);
this.step.set(this.params.initialStep ?? PremiumOrgUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(this.params.selectedPlan ?? null);
}
protected onPlanSelected(
planId: BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId,
): void {
this.selectedPlan.set(planId);
this.nextStep();
}
protected async onCloseClicked(): Promise<void> {
this.close({ status: PremiumOrgUpgradeDialogStatus.Closed });
}
private close(result: PremiumOrgUpgradeDialogResult): void {
this.dialogRef.close(result);
}
protected nextStep() {
if (this.step() === PremiumOrgUpgradeDialogStep.PlanSelection) {
this.step.set(PremiumOrgUpgradeDialogStep.Payment);
}
}
protected async previousStep(): Promise<void> {
// If we are on the payment step and there was no initial step, go back to plan selection this is to prevent
// going back to payment step if the dialog was opened directly to payment step
if (this.step() === PremiumOrgUpgradeDialogStep.Payment && this.params?.initialStep == null) {
this.step.set(PremiumOrgUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(null);
} else {
this.close({ status: PremiumOrgUpgradeDialogStatus.Closed });
}
}
protected async onComplete(result: PremiumOrgUpgradePaymentResult): Promise<void> {
let status: PremiumOrgUpgradeDialogStatus;
switch (result.status) {
case "upgradedToFamilies":
status = PremiumOrgUpgradeDialogStatus.UpgradedToFamilies;
break;
case "upgradedToTeams":
status = PremiumOrgUpgradeDialogStatus.UpgradedToTeams;
break;
case "upgradedToEnterprise":
status = PremiumOrgUpgradeDialogStatus.UpgradedToEnterprise;
break;
case "closed":
status = PremiumOrgUpgradeDialogStatus.Closed;
break;
default:
status = PremiumOrgUpgradeDialogStatus.Closed;
}
this.close({ status, organizationId: result.organizationId });
// Redirect to organization vault after successful upgrade
if (
this.params.redirectOnCompletion &&
(status === PremiumOrgUpgradeDialogStatus.UpgradedToFamilies ||
status === PremiumOrgUpgradeDialogStatus.UpgradedToEnterprise ||
status === PremiumOrgUpgradeDialogStatus.UpgradedToTeams)
) {
const redirectUrl = `/organizations/${result.organizationId}/vault`;
await this.router.navigate([redirectUrl]);
}
}
/**
* Opens the premium org upgrade dialog.
*
* @param dialogService - The dialog service used to open the component
* @param dialogConfig - The configuration for the dialog including PremiumOrgUpgradeDialogParams data
* @returns A dialog reference object of type DialogRef<PremiumOrgUpgradeDialogResult>
*/
static open(
dialogService: DialogService,
dialogConfig: DialogConfig<PremiumOrgUpgradeDialogParams>,
): DialogRef<PremiumOrgUpgradeDialogResult> {
return dialogService.open<PremiumOrgUpgradeDialogResult>(PremiumOrgUpgradeDialogComponent, {
data: dialogConfig.data,
});
}
}

View File

@@ -0,0 +1,65 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
@let changingPayment = isChangingPaymentMethod();
<bit-dialog dialogSize="large" [loading]="loading()">
<span bitDialogTitle class="tw-font-medium">{{ upgradeToMessage() }}</span>
<ng-container bitDialogContent>
<section>
<div class="tw-pb-4">
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="organizationName" required />
<bit-hint bitTypography="helper" class="tw-text-muted">
{{ "organizationNameDescription" | i18n }}
</bit-hint>
</bit-form-field>
</div>
<div class="tw-pb-8 !tw-mx-0">
<app-display-payment-method-inline
[subscriber]="subscriber()"
[paymentMethod]="paymentMethod()"
(updated)="handlePaymentMethodUpdate($event)"
(changingStateChanged)="handlePaymentMethodChangingStateChange($event)"
>
</app-display-payment-method-inline>
@if (!changingPayment) {
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
}
</div>
</section>
<section>
<billing-cart-summary
#cartSummaryComponent
[cart]="cart()"
[hidePricingTerm]="true"
></billing-cart-summary>
</section>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
bitFormButton
buttonType="primary"
[disabled]="loading() || !formGroup.valid"
type="submit"
>
{{ "upgrade" | i18n }}
</button>
<button
bitButton
type="button"
buttonType="secondary"
(click)="goBack.emit()"
[disabled]="loading()"
>
{{ "back" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,576 @@
import {
Component,
input,
ChangeDetectionStrategy,
CUSTOM_ELEMENTS_SCHEMA,
signal,
output,
} from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { mock } 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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { CartSummaryComponent } from "@bitwarden/pricing";
import { AccountBillingClient } from "../../../clients/account-billing.client";
import { PreviewInvoiceClient } from "../../../clients/preview-invoice.client";
import { SubscriberBillingClient } from "../../../clients/subscriber-billing.client";
import {
EnterBillingAddressComponent,
DisplayPaymentMethodInlineComponent,
} from "../../../payment/components";
import {
PremiumOrgUpgradePaymentComponent,
PremiumOrgUpgradePaymentStatus,
} from "./premium-org-upgrade-payment.component";
import { PremiumOrgUpgradeService } from "./services/premium-org-upgrade.service";
// Mock Components
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "billing-cart-summary",
template: `<h1>Mock Cart Summary</h1>`,
providers: [{ provide: CartSummaryComponent, useClass: MockCartSummaryComponent }],
})
class MockCartSummaryComponent {
readonly cart = input.required<any>();
readonly header = input<any>();
readonly isExpanded = signal(false);
readonly hidePricingTerm = input<boolean>(false);
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-display-payment-method-inline",
template: `<h1>Mock Display Payment Method</h1>`,
providers: [
{
provide: DisplayPaymentMethodInlineComponent,
useClass: MockDisplayPaymentMethodInlineComponent,
},
],
})
class MockDisplayPaymentMethodInlineComponent {
readonly subscriber = input.required<any>();
readonly paymentMethod = input<any>();
readonly updated = output<any>();
readonly changePaymentMethodClicked = output<void>();
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-enter-billing-address",
template: `<h1>Mock Enter Billing Address</h1>`,
providers: [
{
provide: EnterBillingAddressComponent,
useClass: MockEnterBillingAddressComponent,
},
],
})
class MockEnterBillingAddressComponent {
readonly scenario = input.required<any>();
readonly group = input.required<any>();
static getFormGroup = () =>
new FormGroup({
country: new FormControl<string>("", {
nonNullable: true,
validators: [Validators.required],
}),
postalCode: new FormControl<string>("", {
nonNullable: true,
validators: [Validators.required],
}),
line1: new FormControl<string | null>(null),
line2: new FormControl<string | null>(null),
city: new FormControl<string | null>(null),
state: new FormControl<string | null>(null),
taxId: new FormControl<string | null>(null),
});
}
describe("PremiumOrgUpgradePaymentComponent", () => {
beforeAll(() => {
// Mock IntersectionObserver - required because DialogComponent uses it to detect scrollable content.
// This browser API doesn't exist in the Jest/Node.js test environment.
// This is necessary because we are unable to mock DialogComponent which is not directly importable
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
} as any;
});
let component: PremiumOrgUpgradePaymentComponent;
let fixture: ComponentFixture<PremiumOrgUpgradePaymentComponent>;
const mockPremiumOrgUpgradeService = mock<PremiumOrgUpgradeService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
const mockToastService = mock<ToastService>();
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockPreviewInvoiceClient = mock<PreviewInvoiceClient>();
const mockLogService = mock<LogService>();
const mockOrganizationService = mock<OrganizationService>();
const mockSubscriberBillingClient = mock<SubscriberBillingClient>();
const mockApiService = mock<ApiService>();
const mockAccountService = mock<AccountService>();
const mockI18nService = { t: jest.fn((key: string, ...params: any[]) => key) };
const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account;
const mockTeamsPlan: BusinessSubscriptionPricingTier = {
id: "teams",
name: "Teams",
description: "Teams plan",
availableCadences: ["annually"],
passwordManager: {
annualPricePerUser: 48,
type: "scalable",
features: [],
},
secretsManager: {
annualPricePerUser: 24,
type: "scalable",
features: [],
},
};
const mockFamiliesPlan: PersonalSubscriptionPricingTier = {
id: "families",
name: "Families",
description: "Families plan",
availableCadences: ["annually"],
passwordManager: {
annualPrice: 40,
users: 6,
type: "packaged",
features: [],
},
};
beforeEach(async () => {
jest.clearAllMocks();
mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined);
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined);
mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({
tax: 5.0,
total: 53.0,
credit: 10.0,
newPlanProratedMonths: 1,
});
mockOrganizationService.organizations$.mockReturnValue(of([]));
mockAccountService.activeAccount$ = of(mockAccount);
mockSubscriberBillingClient.getPaymentMethod.mockResolvedValue({
type: "card",
brand: "visa",
last4: "4242",
expiration: "12/2025",
});
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of([mockTeamsPlan]),
);
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([mockFamiliesPlan]),
);
await TestBed.configureTestingModule({
imports: [PremiumOrgUpgradePaymentComponent],
providers: [
{ provide: PremiumOrgUpgradeService, useValue: mockPremiumOrgUpgradeService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
{ provide: ToastService, useValue: mockToastService },
{ provide: LogService, useValue: mockLogService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
{ provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient },
{ provide: AccountService, useValue: mockAccountService },
{ provide: ApiService, useValue: mockApiService },
{
provide: KeyService,
useValue: {
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
},
},
{
provide: SyncService,
useValue: { fullSync: jest.fn().mockResolvedValue(undefined) },
},
{ provide: OrganizationService, useValue: mockOrganizationService },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(PremiumOrgUpgradePaymentComponent, {
add: {
imports: [
MockEnterBillingAddressComponent,
MockDisplayPaymentMethodInlineComponent,
MockCartSummaryComponent,
],
},
remove: {
imports: [
EnterBillingAddressComponent,
DisplayPaymentMethodInlineComponent,
CartSummaryComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId);
fixture.componentRef.setInput("account", mockAccount);
fixture.detectChanges();
// Wait for ngOnInit to complete
await fixture.whenStable();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize with the correct plan details", () => {
expect(component["selectedPlan"]()).not.toBeNull();
expect(component["selectedPlan"]()?.details.id).toBe("teams");
expect(component["upgradeToMessage"]()).toContain("upgradeToTeams");
});
it("should handle invalid plan id that doesn't exist in pricing tiers", async () => {
// Create a fresh component with an invalid plan ID from the start
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
const newComponent = newFixture.componentInstance;
newFixture.componentRef.setInput(
"selectedPlanId",
"non-existent-plan" as BusinessSubscriptionPricingTierId,
);
newFixture.componentRef.setInput("account", mockAccount);
newFixture.detectChanges();
await newFixture.whenStable();
expect(newComponent["selectedPlan"]()).toBeNull();
});
it("should handle invoice preview errors gracefully", fakeAsync(() => {
mockPremiumOrgUpgradeService.previewProratedInvoice.mockRejectedValue(
new Error("Network error"),
);
// Component should still render and be usable even when invoice preview fails
fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId);
fixture.componentRef.setInput("account", mockAccount);
fixture.detectChanges();
tick();
expect(component).toBeTruthy();
expect(component["selectedPlan"]()).not.toBeNull();
expect(mockToastService.showToast).not.toHaveBeenCalled();
}));
describe("submit", () => {
it("should successfully upgrade to organization", async () => {
const completeSpy = jest.spyOn(component["complete"], "emit");
// Mock processUpgrade to bypass form validation
jest.spyOn(component as any, "processUpgrade").mockResolvedValue({
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
organizationId: null,
});
component["formGroup"].setValue({
organizationName: "My New Org",
billingAddress: {
country: "US",
postalCode: "90210",
line1: "123 Main St",
line2: "",
city: "Beverly Hills",
state: "CA",
taxId: "",
},
});
await component["submit"]();
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "plansUpdated",
});
expect(completeSpy).toHaveBeenCalledWith({
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
organizationId: null,
});
});
it("should show an error toast if upgrade fails", async () => {
// Mock processUpgrade to throw an error
jest
.spyOn(component as any, "processUpgrade")
.mockRejectedValue(new Error("Submission Error"));
component["formGroup"].setValue({
organizationName: "My New Org",
billingAddress: {
country: "US",
postalCode: "90210",
line1: "123 Main St",
line2: "",
city: "Beverly Hills",
state: "CA",
taxId: "",
},
});
await component["submit"]();
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "upgradeErrorMessage",
});
});
it("should not submit if the form is invalid", async () => {
const markAllAsTouchedSpy = jest.spyOn(component["formGroup"], "markAllAsTouched");
component["formGroup"].get("organizationName")?.setValue("");
fixture.detectChanges();
await component["submit"]();
expect(markAllAsTouchedSpy).toHaveBeenCalled();
expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled();
});
});
it("should map plan id to correct upgrade status", () => {
expect(component["getUpgradeStatus"]("families" as PersonalSubscriptionPricingTierId)).toBe(
PremiumOrgUpgradePaymentStatus.UpgradedToFamilies,
);
expect(component["getUpgradeStatus"]("teams" as BusinessSubscriptionPricingTierId)).toBe(
PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
);
expect(component["getUpgradeStatus"]("enterprise" as BusinessSubscriptionPricingTierId)).toBe(
PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise,
);
expect(component["getUpgradeStatus"]("some-other-plan" as any)).toBe(
PremiumOrgUpgradePaymentStatus.Closed,
);
});
describe("Invoice Preview", () => {
it("should return zero values when billing address is incomplete", fakeAsync(() => {
component["formGroup"].patchValue({
organizationName: "Test Org",
billingAddress: {
country: "US",
postalCode: "", // Missing postal code
},
});
// Advance time to allow any async operations to complete
tick(1500);
fixture.detectChanges();
const estimatedInvoice = component["estimatedInvoice"]();
expect(estimatedInvoice.tax).toBe(0);
expect(estimatedInvoice.total).toBe(0);
}));
});
describe("Form Validation", () => {
it("should validate organization name is required", () => {
component["formGroup"].patchValue({ organizationName: "" });
expect(component["formGroup"].get("organizationName")?.invalid).toBe(true);
});
it("should validate organization name when provided", () => {
component["formGroup"].patchValue({ organizationName: "My Organization" });
expect(component["formGroup"].get("organizationName")?.valid).toBe(true);
});
});
describe("Cart Calculation", () => {
it("should calculate cart with correct values for selected plan", () => {
const cart = component["cart"]();
expect(cart.passwordManager.seats.cost).toBe(48); // Teams annual price per user
expect(cart.passwordManager.seats.quantity).toBe(1);
expect(cart.cadence).toBe("annually");
});
it("should return default cart when no plan is selected", () => {
component["selectedPlan"].set(null);
const cart = component["cart"]();
expect(cart.passwordManager.seats.cost).toBe(0);
expect(cart.passwordManager.seats.quantity).toBe(0);
expect(cart.estimatedTax).toBe(0);
});
});
describe("ngAfterViewInit", () => {
it("should collapse cart summary after view init", () => {
const mockCartSummary = {
isExpanded: signal(true),
} as any;
jest.spyOn(component, "cartSummaryComponent").mockReturnValue(mockCartSummary);
component.ngAfterViewInit();
expect(mockCartSummary.isExpanded()).toBe(false);
});
});
describe("Plan Price Calculation", () => {
it("should calculate price for personal plan with annualPrice", () => {
const price = component["getPlanPrice"](mockFamiliesPlan);
expect(price).toBe(40);
});
it("should calculate price for business plan with annualPricePerUser", () => {
const price = component["getPlanPrice"](mockTeamsPlan);
expect(price).toBe(48);
});
it("should return 0 when passwordManager is missing", () => {
const invalidPlan = { ...mockTeamsPlan, passwordManager: undefined } as any;
const price = component["getPlanPrice"](invalidPlan);
expect(price).toBe(0);
});
});
describe("processUpgrade", () => {
beforeEach(() => {
// Set paymentMethod signal for these tests
component["paymentMethod"].set({
type: "card",
brand: "visa",
last4: "4242",
expiration: "12/2025",
});
});
it("should throw error when billing address is incomplete", async () => {
component["formGroup"].patchValue({
organizationName: "Test Org",
billingAddress: {
country: "",
postalCode: "",
},
});
await expect(component["processUpgrade"]()).rejects.toThrow("Billing address is incomplete");
});
it("should throw error when organization name is missing", async () => {
component["formGroup"].patchValue({
organizationName: "",
billingAddress: {
country: "US",
postalCode: "12345",
},
});
await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required");
});
});
describe("Plan Membership Messages", () => {
it("should return correct membership message for families plan", async () => {
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
const newComponent = newFixture.componentInstance;
newFixture.componentRef.setInput(
"selectedPlanId",
"families" as PersonalSubscriptionPricingTierId,
);
newFixture.componentRef.setInput("account", mockAccount);
newFixture.detectChanges();
await newFixture.whenStable();
expect(newComponent["planMembershipMessage"]()).toBe("familiesMembership");
});
it("should return correct membership message for teams plan", () => {
expect(component["planMembershipMessage"]()).toBe("teamsMembership");
});
it("should return correct membership message for enterprise plan", async () => {
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
const newComponent = newFixture.componentInstance;
newFixture.componentRef.setInput(
"selectedPlanId",
"enterprise" as BusinessSubscriptionPricingTierId,
);
newFixture.componentRef.setInput("account", mockAccount);
newFixture.detectChanges();
await newFixture.whenStable();
expect(newComponent["planMembershipMessage"]()).toBe("enterpriseMembership");
});
});
describe("Error Handling", () => {
it("should log error and continue when submit fails", async () => {
jest.spyOn(component as any, "processUpgrade").mockRejectedValue(new Error("Network error"));
component["formGroup"].setValue({
organizationName: "My New Org",
billingAddress: {
country: "US",
postalCode: "90210",
line1: "123 Main St",
line2: "",
city: "Beverly Hills",
state: "CA",
taxId: "",
},
});
await component["submit"]();
expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", expect.any(Error));
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "upgradeErrorMessage",
});
});
});
describe("goBack Output", () => {
it("should emit goBack event when back action is triggered", () => {
const goBackSpy = jest.spyOn(component["goBack"], "emit");
component["goBack"].emit();
expect(goBackSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,469 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
input,
OnInit,
output,
signal,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import {
catchError,
of,
combineLatest,
startWith,
debounceTime,
switchMap,
Observable,
from,
defer,
map,
tap,
} from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { Cart, CartSummaryComponent } from "@bitwarden/pricing";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SubscriberBillingClient } from "../../../clients/subscriber-billing.client";
import {
EnterBillingAddressComponent,
getBillingAddressFromForm,
DisplayPaymentMethodInlineComponent,
} from "../../../payment/components";
import { MaskedPaymentMethod } from "../../../payment/types";
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../../types";
import {
PremiumOrgUpgradeService,
PremiumOrgUpgradePlanDetails,
InvoicePreview,
} from "./services/premium-org-upgrade.service";
export const PremiumOrgUpgradePaymentStatus = {
Closed: "closed",
UpgradedToTeams: "upgradedToTeams",
UpgradedToEnterprise: "upgradedToEnterprise",
UpgradedToFamilies: "upgradedToFamilies",
} as const;
export type PremiumOrgUpgradePaymentStatus = UnionOfValues<typeof PremiumOrgUpgradePaymentStatus>;
export type PremiumOrgUpgradePaymentResult = {
status: PremiumOrgUpgradePaymentStatus;
organizationId?: string | null;
};
@Component({
selector: "app-premium-org-upgrade-payment",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DialogModule,
SharedModule,
CartSummaryComponent,
ButtonModule,
EnterBillingAddressComponent,
DisplayPaymentMethodInlineComponent,
],
providers: [PremiumOrgUpgradeService],
templateUrl: "./premium-org-upgrade-payment.component.html",
})
export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit {
private readonly INITIAL_TAX_VALUE = 0;
private readonly DEFAULT_SEAT_COUNT = 1;
private readonly DEFAULT_CADENCE = "annually";
private readonly PLAN_MEMBERSHIP_MESSAGES: Record<string, string> = {
families: "familiesMembership",
teams: "teamsMembership",
enterprise: "enterpriseMembership",
};
private readonly UPGRADE_STATUS_MAP: Record<string, PremiumOrgUpgradePaymentStatus> = {
families: PremiumOrgUpgradePaymentStatus.UpgradedToFamilies,
teams: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
enterprise: PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise,
};
private readonly UPGRADE_MESSAGE_KEYS: Record<string, string> = {
families: "upgradeToFamilies",
teams: "upgradeToTeams",
enterprise: "upgradeToEnterprise",
};
protected readonly selectedPlanId = input.required<
PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId
>();
protected readonly account = input.required<Account>();
protected goBack = output<void>();
protected complete = output<PremiumOrgUpgradePaymentResult>();
readonly cartSummaryComponent = viewChild.required(CartSummaryComponent);
protected formGroup = new FormGroup({
organizationName: new FormControl<string>("", [Validators.required]),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
protected readonly selectedPlan = signal<PremiumOrgUpgradePlanDetails | null>(null);
protected readonly loading = signal(true);
protected readonly upgradeToMessage = signal("");
// Signals for payment method
protected readonly paymentMethod = signal<MaskedPaymentMethod | null>(null);
protected readonly subscriber = signal<BitwardenSubscriber | null>(null);
/**
* Indicates whether the payment method is currently being changed.
* This is used to disable the submit button while a payment method change is in progress.
* or to hide other UI elements as needed.
*/
protected readonly isChangingPaymentMethod = signal(false);
protected readonly planMembershipMessage = computed<string>(
() => this.PLAN_MEMBERSHIP_MESSAGES[this.selectedPlanId()] ?? "",
);
// Use defer to lazily create the observable when subscribed to
protected estimatedInvoice$ = defer(() =>
combineLatest([this.formGroup.controls.billingAddress.valueChanges]).pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
switchMap(() => this.refreshInvoicePreview$()),
),
);
protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, {
initialValue: this.getEmptyInvoicePreview(),
});
private readonly i18nService = inject(I18nService);
private readonly subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction);
private readonly toastService = inject(ToastService);
private readonly logService = inject(LogService);
private readonly destroyRef = inject(DestroyRef);
private readonly premiumOrgUpgradeService = inject(PremiumOrgUpgradeService);
private readonly subscriberBillingClient = inject(SubscriberBillingClient);
private readonly accountService = inject(AccountService);
constructor() {}
// Cart Summary data
protected readonly cart = computed<Cart>(() => {
if (!this.selectedPlan()) {
return {
hidePricingTerm: true,
passwordManager: {
seats: {
translationKey: this.planMembershipMessage(),
cost: 0,
quantity: 0,
hideBreakdown: true,
},
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.INITIAL_TAX_VALUE,
};
}
return {
hidePricingTerm: true,
passwordManager: {
seats: {
translationKey: this.getMembershipTranslationKey(),
translationParams: this.getMembershipTranslationParams(),
cost: this.getCartCost(),
quantity: this.DEFAULT_SEAT_COUNT,
hideBreakdown: true,
},
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.estimatedInvoice().tax,
credit: {
value: this.estimatedInvoice().credit,
translationKey: "premiumSubscriptionCredit",
},
};
});
async ngOnInit(): Promise<void> {
// If the selected plan is Personal Premium, no upgrade is needed
if (this.selectedPlanId() == PersonalSubscriptionPricingTierIds.Premium) {
this.complete.emit({
status: PremiumOrgUpgradePaymentStatus.Closed,
organizationId: null,
});
return;
}
combineLatest([
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(),
this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(),
])
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([personalPlans, businessPlans]) => {
const plans: (PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier)[] = [
...personalPlans,
...businessPlans,
];
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
if (planDetails) {
this.setSelectedPlan(planDetails);
this.setUpgradeMessage(planDetails);
} else {
this.complete.emit({
status: PremiumOrgUpgradePaymentStatus.Closed,
organizationId: null,
});
return;
}
});
this.accountService.activeAccount$
.pipe(
mapAccountToSubscriber,
switchMap((subscriber) =>
from(this.subscriberBillingClient.getPaymentMethod(subscriber)).pipe(
map((paymentMethod) => ({ subscriber, paymentMethod })),
),
),
tap(({ subscriber, paymentMethod }) => {
this.subscriber.set(subscriber);
this.paymentMethod.set(paymentMethod);
this.loading.set(false);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
ngAfterViewInit(): void {
const cartSummaryComponent = this.cartSummaryComponent();
cartSummaryComponent.isExpanded.set(false);
}
/**
* Updates the payment method when changed through the DisplayPaymentMethodComponent.
* @param newPaymentMethod The updated payment method details
*/
handlePaymentMethodUpdate(newPaymentMethod: MaskedPaymentMethod) {
this.paymentMethod.set(newPaymentMethod);
}
/**
* Handles changes to the payment method changing state.
* @param isChanging Whether the payment method is currently being changed
*/
handlePaymentMethodChangingStateChange(isChanging: boolean) {
this.isChangingPaymentMethod.set(isChanging);
}
protected submit = async (): Promise<void> => {
if (!this.formGroup.valid) {
this.formGroup.markAllAsTouched();
return;
}
if (!this.selectedPlan()) {
throw new Error("No plan selected");
}
try {
const result = await this.processUpgrade();
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("plansUpdated", this.selectedPlan()?.details.name),
});
this.complete.emit(result);
} catch (error: unknown) {
this.logService.error("Upgrade failed:", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("upgradeErrorMessage"),
});
}
};
private async processUpgrade(): Promise<PremiumOrgUpgradePaymentResult> {
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const organizationName = this.formGroup.value?.organizationName;
if (!billingAddress.country || !billingAddress.postalCode) {
throw new Error("Billing address is incomplete");
}
if (!organizationName) {
throw new Error("Organization name is required");
}
const organizationId = await this.premiumOrgUpgradeService.upgradeToOrganization(
this.account(),
organizationName,
this.selectedPlan()!,
billingAddress,
);
return {
status: this.getUpgradeStatus(this.selectedPlanId()),
organizationId,
};
}
private getUpgradeStatus(planId: string): PremiumOrgUpgradePaymentStatus {
return this.UPGRADE_STATUS_MAP[planId] ?? PremiumOrgUpgradePaymentStatus.Closed;
}
/**
* Gets the appropriate translation key for the membership display.
* Returns a prorated message if the plan has prorated months, otherwise returns the standard plan message.
*/
private getMembershipTranslationKey(): string {
return this.estimatedInvoice()?.newPlanProratedMonths > 0
? "planProratedMembershipInMonths"
: this.planMembershipMessage();
}
/**
* Gets the translation parameters for the membership display.
* For prorated plans, returns an array with the plan name and formatted month duration.
* For non-prorated plans, returns an empty array.
*/
private getMembershipTranslationParams(): string[] {
if (this.estimatedInvoice()?.newPlanProratedMonths > 0) {
const months = this.estimatedInvoice()!.newPlanProratedMonths;
const monthLabel = this.formatMonthLabel(months);
return [this.selectedPlan()!.details.name, monthLabel];
}
return [];
}
/**
* Formats month count into a readable string (e.g., "1 month", "3 months").
*/
private formatMonthLabel(months: number): string {
return `${months} month${months > 1 ? "s" : ""}`;
}
/**
* Calculates the cart cost, using prorated amount if available, otherwise the plan cost.
*/
private getCartCost(): number {
const proratedAmount = this.estimatedInvoice().newPlanProratedAmount;
return proratedAmount && proratedAmount > 0 ? proratedAmount : this.selectedPlan()!.cost;
}
/**
* Sets the selected plan with tier, details, and cost.
*/
private setSelectedPlan(
planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
): void {
this.selectedPlan.set({
tier: this.selectedPlanId(),
details: planDetails,
cost: this.getPlanPrice(planDetails),
});
}
/**
* Sets the upgrade message based on the selected plan.
*/
private setUpgradeMessage(
planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
): void {
const messageKey = this.UPGRADE_MESSAGE_KEYS[this.selectedPlanId()];
const message = messageKey ? this.i18nService.t(messageKey, planDetails.name) : "";
this.upgradeToMessage.set(message);
}
/**
* Calculates the price for the currently selected plan.
*
* This method retrieves the `passwordManager` details from the selected plan. It then determines
* the appropriate price based on the properties available on the `passwordManager` object.
* It prioritizes `annualPrice` for individual-style plans and falls back to `annualPricePerUser`
* for user-based plans.
*
* @returns The annual price of the plan as a number. Returns `0` if the plan or its price cannot be determined.
*/
private getPlanPrice(
plan: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
): number {
const passwordManager = plan.passwordManager;
if (!passwordManager) {
return 0;
}
if ("annualPrice" in passwordManager) {
return passwordManager.annualPrice ?? 0;
} else if ("annualPricePerUser" in passwordManager) {
return passwordManager.annualPricePerUser ?? 0;
}
return 0;
}
/**
* Returns an empty invoice preview with default values.
*/
private getEmptyInvoicePreview(): InvoicePreview {
return {
tax: this.INITIAL_TAX_VALUE,
total: 0,
credit: 0,
newPlanProratedMonths: 0,
newPlanProratedAmount: 0,
};
}
/**
* Refreshes the invoice preview based on the current form state.
*/
private refreshInvoicePreview$(): Observable<InvoicePreview> {
if (this.formGroup.invalid || !this.selectedPlan()) {
return of(this.getEmptyInvoicePreview());
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
if (!billingAddress.country || !billingAddress.postalCode) {
return of(this.getEmptyInvoicePreview());
}
return from(
this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress),
).pipe(
catchError((error: unknown) => {
this.logService.error("Invoice preview failed:", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("invoicePreviewErrorMessage"),
});
return of(this.getEmptyInvoicePreview());
}),
);
}
}

View File

@@ -0,0 +1,266 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BusinessSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { AccountBillingClient } from "../../../../clients/account-billing.client";
import { PreviewInvoiceClient } from "../../../../clients/preview-invoice.client";
import { BillingAddress } from "../../../../payment/types";
import {
PremiumOrgUpgradePlanDetails,
PremiumOrgUpgradeService,
} from "./premium-org-upgrade.service";
describe("PremiumOrgUpgradeService", () => {
let service: PremiumOrgUpgradeService;
let accountBillingClient: jest.Mocked<AccountBillingClient>;
let previewInvoiceClient: jest.Mocked<PreviewInvoiceClient>;
let syncService: jest.Mocked<SyncService>;
let keyService: jest.Mocked<KeyService>;
let organizationService: jest.Mocked<OrganizationService>;
const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account;
const mockPlanDetails: PremiumOrgUpgradePlanDetails = {
tier: BusinessSubscriptionPricingTierIds.Teams,
details: {
id: BusinessSubscriptionPricingTierIds.Teams,
name: "Teams",
passwordManager: {
annualPrice: 48,
users: 1,
},
},
} as any;
const mockBillingAddress: BillingAddress = {
country: "US",
postalCode: "12345",
line1: null,
line2: null,
city: null,
state: null,
taxId: null,
};
beforeEach(() => {
accountBillingClient = {
upgradePremiumToOrganization: jest.fn().mockResolvedValue(undefined),
} as any;
previewInvoiceClient = {
previewProrationForPremiumUpgrade: jest
.fn()
.mockResolvedValue({ tax: 5, total: 55, credit: 0 }),
} as any;
syncService = {
fullSync: jest.fn().mockResolvedValue(undefined),
} as any;
keyService = {
makeOrgKey: jest
.fn()
.mockResolvedValue([{ encryptedString: "encrypted-string" }, "decrypted-key"]),
} as any;
organizationService = {
organizations$: jest.fn().mockReturnValue(
of([
{
id: "new-org-id",
name: "Test Organization",
isOwner: true,
} as Organization,
]),
),
} as any;
TestBed.configureTestingModule({
providers: [
PremiumOrgUpgradeService,
{ provide: AccountBillingClient, useValue: accountBillingClient },
{ provide: PreviewInvoiceClient, useValue: previewInvoiceClient },
{ provide: SyncService, useValue: syncService },
{ provide: AccountService, useValue: { activeAccount$: of(mockAccount) } },
{ provide: KeyService, useValue: keyService },
{ provide: OrganizationService, useValue: organizationService },
],
});
service = TestBed.inject(PremiumOrgUpgradeService);
});
describe("upgradeToOrganization", () => {
it("should successfully upgrade premium account to organization and return organization ID", async () => {
const result = await service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
);
expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith(
"Test Organization",
"encrypted-string",
2, // ProductTierType.Teams
"annually",
mockBillingAddress,
);
expect(keyService.makeOrgKey).toHaveBeenCalledWith("user-id");
expect(syncService.fullSync).toHaveBeenCalledWith(true);
expect(organizationService.organizations$).toHaveBeenCalledWith("user-id");
expect(result).toBe("new-org-id");
});
it("should throw an error if organization name is missing", async () => {
await expect(
service.upgradeToOrganization(mockAccount, "", mockPlanDetails, mockBillingAddress),
).rejects.toThrow("Organization name is required for organization upgrade");
});
it("should throw an error if billing address is incomplete", async () => {
const incompleteBillingAddress: BillingAddress = {
country: "",
postalCode: "",
line1: null,
line2: null,
city: null,
state: null,
taxId: null,
};
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
incompleteBillingAddress,
),
).rejects.toThrow("Billing address information is incomplete");
});
it("should throw an error for invalid plan tier", async () => {
const invalidPlanDetails = {
tier: "invalid-tier" as any,
details: mockPlanDetails.details,
cost: 0,
};
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
invalidPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Invalid plan tier for organization upgrade");
});
it("should propagate error if key generation fails", async () => {
keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed"));
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Key generation failed");
});
it("should throw an error if encrypted string is undefined", async () => {
keyService.makeOrgKey.mockResolvedValue([
{ encryptedString: null } as any,
"decrypted-key" as any,
]);
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Failed to generate encrypted organization key");
});
it("should propagate error if upgrade API call fails", async () => {
accountBillingClient.upgradePremiumToOrganization.mockRejectedValue(
new Error("API call failed"),
);
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("API call failed");
});
it("should propagate error if sync fails", async () => {
syncService.fullSync.mockRejectedValue(new Error("Sync failed"));
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Sync failed");
});
it("should throw an error if organization is not found after sync", async () => {
organizationService.organizations$.mockReturnValue(
of([
{
id: "different-org-id",
name: "Different Organization",
isOwner: true,
} as Organization,
]),
);
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Failed to find newly created organization");
});
it("should throw an error if no organizations are returned", async () => {
organizationService.organizations$.mockReturnValue(of([]));
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Failed to find newly created organization");
});
});
describe("previewProratedInvoice", () => {
it("should call previewProrationForPremiumUpgrade and return invoice preview", async () => {
const result = await service.previewProratedInvoice(mockPlanDetails, mockBillingAddress);
expect(result).toEqual({ tax: 5, total: 55, credit: 0 });
expect(previewInvoiceClient.previewProrationForPremiumUpgrade).toHaveBeenCalledWith(
2, // ProductTierType.Teams
mockBillingAddress,
);
});
it("should throw an error if invoice preview fails", async () => {
previewInvoiceClient.previewProrationForPremiumUpgrade.mockRejectedValue(
new Error("Invoice API error"),
);
await expect(
service.previewProratedInvoice(mockPlanDetails, mockBillingAddress),
).rejects.toThrow("Invoice API error");
});
});
});

View File

@@ -0,0 +1,129 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { OrgKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { AccountBillingClient, PreviewInvoiceClient } from "../../../../clients";
import { BillingAddress } from "../../../../payment/types";
export type PremiumOrgUpgradePlanDetails = {
tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId;
details: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier;
cost: number;
proratedAmount?: number;
};
export type PaymentFormValues = {
organizationName?: string | null;
billingAddress: {
country: string;
postalCode: string;
};
};
export interface InvoicePreview {
tax: number;
total: number;
credit: number;
newPlanProratedMonths: number;
newPlanProratedAmount?: number;
}
@Injectable()
export class PremiumOrgUpgradeService {
constructor(
private accountBillingClient: AccountBillingClient,
private previewInvoiceClient: PreviewInvoiceClient,
private syncService: SyncService,
private keyService: KeyService,
private organizationService: OrganizationService,
) {}
async previewProratedInvoice(
planDetails: PremiumOrgUpgradePlanDetails,
billingAddress: BillingAddress,
): Promise<InvoicePreview> {
const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier);
const invoicePreviewResponse =
await this.previewInvoiceClient.previewProrationForPremiumUpgrade(tier, billingAddress);
return {
tax: invoicePreviewResponse.tax,
total: invoicePreviewResponse.total,
credit: invoicePreviewResponse.credit,
newPlanProratedMonths: invoicePreviewResponse.newPlanProratedMonths,
newPlanProratedAmount: invoicePreviewResponse.newPlanProratedAmount,
};
}
async upgradeToOrganization(
account: Account,
organizationName: string,
planDetails: PremiumOrgUpgradePlanDetails,
billingAddress: BillingAddress,
): Promise<string> {
if (!organizationName) {
throw new Error("Organization name is required for organization upgrade");
}
if (!billingAddress?.country || !billingAddress?.postalCode) {
throw new Error("Billing address information is incomplete");
}
const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier);
const [encryptedKey] = await this.keyService.makeOrgKey<OrgKey>(account.id);
if (!encryptedKey.encryptedString) {
throw new Error("Failed to generate encrypted organization key");
}
await this.accountBillingClient.upgradePremiumToOrganization(
organizationName,
encryptedKey.encryptedString,
tier,
SubscriptionCadenceIds.Annually,
billingAddress,
);
await this.syncService.fullSync(true);
// Get the newly created organization
const organizations = await firstValueFrom(this.organizationService.organizations$(account.id));
const newOrg = organizations?.find((org) => org.name === organizationName && org.isOwner);
if (!newOrg) {
throw new Error("Failed to find newly created organization");
}
return newOrg.id;
}
private ProductTierTypeFromSubscriptionTierId(
tierId: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId,
): ProductTierType {
switch (tierId) {
case "families":
return ProductTierType.Families;
case "teams":
return ProductTierType.Teams;
case "enterprise":
return ProductTierType.Enterprise;
default:
throw new Error("Invalid plan tier for organization upgrade");
}
}
}

View File

@@ -0,0 +1,82 @@
@if (!loading()) {
<section
class="tw-w-screen tw-max-h-screen tw-min-w-[332px] md:tw-max-w-6xl tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
<header class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2">
<button
cdkFocusInitial
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
[label]="'close' | i18n"
(click)="closeClicked.emit(closedStatus)"
></button>
</header>
<div class="tw-px-14 tw-py-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-medium tw-text-[32px]">
{{ "upgradeYourPlan" | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">
{{ "upgradeShareEvenMore" | i18n }}
</p>
</div>
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 lg:tw-grid-cols-3 tw-gap-5 tw-mb-4">
@if (familiesCardDetails) {
<billing-pricing-card
class="tw-w-full tw-min-w-[216px]"
[tagline]="familiesCardDetails.tagline"
[price]="familiesCardDetails.price"
[button]="familiesCardDetails.button"
[features]="familiesCardDetails.features"
(buttonClick)="planSelected.emit(familiesPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ familiesCardDetails.title }}
</h3>
</billing-pricing-card>
}
@if (teamsCardDetails) {
<billing-pricing-card
class="tw-w-full tw-min-w-[216px]"
[tagline]="teamsCardDetails.tagline"
[price]="teamsCardDetails.price"
[button]="teamsCardDetails.button"
[features]="teamsCardDetails.features"
(buttonClick)="planSelected.emit(teamsPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ teamsCardDetails.title }}
</h3>
</billing-pricing-card>
}
@if (enterpriseCardDetails) {
<billing-pricing-card
class="tw-w-full tw-min-w-[216px]"
[tagline]="enterpriseCardDetails.tagline"
[price]="enterpriseCardDetails.price"
[button]="enterpriseCardDetails.button"
[features]="enterpriseCardDetails.features"
(buttonClick)="planSelected.emit(enterprisePlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ enterpriseCardDetails.title }}
</h3>
</billing-pricing-card>
}
</div>
<div class="tw-text-center tw-w-full">
<p bitTypography="helper" class="tw-text-muted tw-italic">
{{ "organizationUpgradeTaxInformationMessage" | i18n }}
</p>
</div>
</div>
</section>
}

View File

@@ -0,0 +1,221 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { of, throwError } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { BillingServicesModule } from "../../../services";
import { PremiumOrgUpgradePlanSelectionComponent } from "./premium-org-upgrade-plan-selection.component";
describe("PremiumOrgUpgradePlanSelectionComponent", () => {
let sut: PremiumOrgUpgradePlanSelectionComponent;
let fixture: ComponentFixture<PremiumOrgUpgradePlanSelectionComponent>;
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
const mockToastService = mock<ToastService>();
// Mock pricing tiers data
const mockPersonalPricingTiers: PersonalSubscriptionPricingTier[] = [
{
id: PersonalSubscriptionPricingTierIds.Families,
name: "planNameFamilies",
description: "Family plan for up to 6 users",
passwordManager: {
type: "packaged",
annualPrice: 40,
features: [
{ key: "feature1", value: "Feature A" },
{ key: "feature2", value: "Feature B" },
{ key: "feature3", value: "Feature C" },
],
users: 6,
},
} as PersonalSubscriptionPricingTier,
];
const mockBusinessPricingTiers: BusinessSubscriptionPricingTier[] = [
{
id: BusinessSubscriptionPricingTierIds.Teams,
name: "planNameTeams",
description: "Teams plan for growing businesses",
passwordManager: {
type: "scalable",
annualPricePerUser: 48,
features: [
{ key: "teamFeature1", value: "Teams Feature 1" },
{ key: "teamFeature2", value: "Teams Feature 2" },
{ key: "teamFeature3", value: "Teams Feature 3" },
],
},
} as BusinessSubscriptionPricingTier,
{
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: "planNameEnterprise",
description: "Enterprise plan for large organizations",
passwordManager: {
type: "scalable",
annualPricePerUser: 72,
features: [
{ key: "entFeature1", value: "Enterprise Feature 1" },
{ key: "entFeature2", value: "Enterprise Feature 2" },
{ key: "entFeature3", value: "Enterprise Feature 3" },
],
},
} as BusinessSubscriptionPricingTier,
];
beforeEach(async () => {
jest.resetAllMocks();
mockI18nService.t.mockImplementation((key) => key);
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of(mockPersonalPricingTiers),
);
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of(mockBusinessPricingTiers),
);
await TestBed.configureTestingModule({
imports: [PremiumOrgUpgradePlanSelectionComponent, PricingCardComponent, CdkTrapFocus],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
{ provide: ToastService, useValue: mockToastService },
],
})
.overrideComponent(PremiumOrgUpgradePlanSelectionComponent, {
remove: { imports: [BillingServicesModule] },
})
.compileComponents();
fixture = TestBed.createComponent(PremiumOrgUpgradePlanSelectionComponent);
sut = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(sut).toBeTruthy();
});
it("should set loading to false after pricing tiers are loaded", () => {
expect(sut["loading"]()).toBe(false);
});
it("should set up pricing tier details for all three plans", () => {
expect(sut["familiesCardDetails"]).toBeDefined();
expect(sut["teamsCardDetails"]).toBeDefined();
expect(sut["enterpriseCardDetails"]).toBeDefined();
});
describe("card details creation", () => {
it("should create families card details correctly", () => {
expect(sut["familiesCardDetails"].title).toBe("planNameFamilies");
expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users");
expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12);
expect(sut["familiesCardDetails"].price.cadence).toBe("month");
expect(sut["familiesCardDetails"].button.type).toBe("primary");
expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies");
expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]);
});
it("should create teams card details correctly", () => {
expect(sut["teamsCardDetails"].title).toBe("planNameTeams");
expect(sut["teamsCardDetails"].tagline).toBe("Teams plan for growing businesses");
expect(sut["teamsCardDetails"].price.amount).toBe(48 / 12);
expect(sut["teamsCardDetails"].price.cadence).toBe("month");
expect(sut["teamsCardDetails"].button.type).toBe("secondary");
expect(sut["teamsCardDetails"].button.text).toBe("upgradeToTeams");
expect(sut["teamsCardDetails"].features).toEqual([
"Teams Feature 1",
"Teams Feature 2",
"Teams Feature 3",
]);
});
it("should create enterprise card details correctly", () => {
expect(sut["enterpriseCardDetails"].title).toBe("planNameEnterprise");
expect(sut["enterpriseCardDetails"].tagline).toBe("Enterprise plan for large organizations");
expect(sut["enterpriseCardDetails"].price.amount).toBe(72 / 12);
expect(sut["enterpriseCardDetails"].price.cadence).toBe("month");
expect(sut["enterpriseCardDetails"].button.type).toBe("secondary");
expect(sut["enterpriseCardDetails"].button.text).toBe("upgradeToEnterprise");
expect(sut["enterpriseCardDetails"].features).toEqual([
"Enterprise Feature 1",
"Enterprise Feature 2",
"Enterprise Feature 3",
]);
});
});
describe("plan selection", () => {
it("should emit planSelected with families pricing tier when families plan is selected", () => {
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// The first PricingCardComponent corresponds to the families plan
const familiesCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[0];
familiesCard.triggerEventHandler("buttonClick", {});
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families);
});
it("should emit planSelected with teams pricing tier when teams plan is selected", () => {
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// The second PricingCardComponent corresponds to the teams plan
const teamsCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[1];
teamsCard.triggerEventHandler("buttonClick", {});
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Teams);
});
it("should emit planSelected with enterprise pricing tier when enterprise plan is selected", () => {
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// The third PricingCardComponent corresponds to the enterprise plan
const enterpriseCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[2];
enterpriseCard.triggerEventHandler("buttonClick", {});
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Enterprise);
});
});
describe("error handling", () => {
it("should show toast and set loading to false on error", () => {
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
throwError(() => new Error("API Error")),
);
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of(mockBusinessPricingTiers),
);
fixture = TestBed.createComponent(PremiumOrgUpgradePlanSelectionComponent);
sut = fixture.componentInstance;
fixture.detectChanges();
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "unexpectedError",
});
expect(sut["loading"]()).toBe(false);
expect(sut["familiesCardDetails"]).toBeUndefined();
expect(sut["teamsCardDetails"]).toBeUndefined();
expect(sut["enterpriseCardDetails"]).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,169 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import {
Component,
DestroyRef,
OnInit,
output,
signal,
ChangeDetectionStrategy,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, catchError, of } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
export const PremiumOrgUpgradeStatus = {
Closed: "closed",
ProceededToPayment: "proceeded-to-payment",
} as const;
export type PremiumOrgUpgradeStatus = UnionOfValues<typeof PremiumOrgUpgradeStatus>;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-premium-org-upgrade-plan-selection",
imports: [
CommonModule,
DialogModule,
SharedModule,
BillingServicesModule,
PricingCardComponent,
CdkTrapFocus,
],
templateUrl: "./premium-org-upgrade-plan-selection.component.html",
})
export class PremiumOrgUpgradePlanSelectionComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId>();
closeClicked = output<PremiumOrgUpgradeStatus>();
protected closedStatus = PremiumOrgUpgradeStatus.Closed;
protected readonly loading = signal(true);
protected familiesCardDetails!: SubscriptionPricingCardDetails;
protected teamsCardDetails!: SubscriptionPricingCardDetails;
protected enterpriseCardDetails!: SubscriptionPricingCardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected teamsPlanType = BusinessSubscriptionPricingTierIds.Teams;
protected enterprisePlanType = BusinessSubscriptionPricingTierIds.Enterprise;
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private destroyRef: DestroyRef,
) {}
async ngOnInit(): Promise<void> {
combineLatest([
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(),
this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(),
])
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([[], []]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([personalPlans, businessPlans]) => {
this.setupCardDetails(personalPlans, businessPlans);
this.loading.set(false);
});
}
private setupCardDetails(
personalPlans: PersonalSubscriptionPricingTier[],
businessPlans: BusinessSubscriptionPricingTier[],
): void {
const familiesTier = personalPlans.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Families,
);
const teamsTier = businessPlans.find(
(tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams,
);
const enterpriseTier = businessPlans.find(
(tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise,
);
if (familiesTier) {
this.familiesCardDetails = this.createCardDetails(familiesTier, "primary");
}
if (teamsTier) {
this.teamsCardDetails = this.createCardDetails(teamsTier, "secondary");
}
if (enterpriseTier) {
this.enterpriseCardDetails = this.createCardDetails(enterpriseTier, "secondary");
}
}
private createCardDetails(
tier: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
buttonType: ButtonType,
): SubscriptionPricingCardDetails {
let buttonText: string;
switch (tier.id) {
case PersonalSubscriptionPricingTierIds.Families:
buttonText = "upgradeToFamilies";
break;
case BusinessSubscriptionPricingTierIds.Teams:
buttonText = "upgradeToTeams";
break;
case BusinessSubscriptionPricingTierIds.Enterprise:
buttonText = "upgradeToEnterprise";
break;
default:
buttonText = "";
}
let priceAmount: number | undefined;
let shouldShowPerUser = false;
if ("annualPrice" in tier.passwordManager) {
priceAmount = tier.passwordManager.annualPrice;
} else if ("annualPricePerUser" in tier.passwordManager) {
priceAmount = tier.passwordManager.annualPricePerUser;
shouldShowPerUser = true;
}
return {
title: tier.name,
tagline: tier.description,
price:
priceAmount && priceAmount > 0
? {
amount: priceAmount / 12,
cadence: "month",
showPerUser: shouldShowPerUser,
}
: undefined,
button: {
text: this.i18nService.t(buttonText),
type: buttonType,
},
features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value),
};
}
}

View File

@@ -33,6 +33,7 @@ import {
selector: "app-upgrade-account",
template: "",
standalone: true,
providers: [UpgradeAccountComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockUpgradeAccountComponent {
@@ -46,6 +47,7 @@ class MockUpgradeAccountComponent {
selector: "app-upgrade-payment",
template: "",
standalone: true,
providers: [UpgradePaymentComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockUpgradePaymentComponent {
@@ -61,7 +63,6 @@ describe("UnifiedUpgradeDialogComponent", () => {
const mockDialogRef = mock<DialogRef>();
const mockRouter = mock<Router>();
const mockPremiumInterestStateService = mock<PremiumInterestStateService>();
const mockAccount: Account = {
id: "user-id" as UserId,
...mockAccountInfoWith({
@@ -126,9 +127,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
// Default mock: no premium interest
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
imports: [UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
@@ -401,4 +401,54 @@ describe("UnifiedUpgradeDialogComponent", () => {
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("Child Component Display Logic", () => {
it("should display app-upgrade-account on plan selection step", async () => {
const { fixture } = await createComponentWithDialogData(defaultDialogData);
const upgradeAccountElement = fixture.nativeElement.querySelector("app-upgrade-account");
expect(upgradeAccountElement).toBeTruthy();
});
it("should display app-upgrade-payment on payment step", async () => {
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
const { fixture } = await createComponentWithDialogData(customDialogData);
const upgradePaymentElement = fixture.nativeElement.querySelector("app-upgrade-payment");
expect(upgradePaymentElement).toBeTruthy();
});
});
describe("redirectOnCompletion", () => {
it("should handle redirectOnCompletion for families upgrade with organization", async () => {
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockRouter.navigate.mockResolvedValue(true);
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
const result = {
status: "upgradedToFamilies" as const,
organizationId: "org-789",
};
await customComponent["onComplete"](result);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-789/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-789",
});
});
});
});

View File

@@ -6,6 +6,7 @@
>
<header class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2">
<button
cdkFocusInitial
type="button"
bitIconButton="bwi-close"
buttonType="main"

View File

@@ -24,10 +24,10 @@
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="organizationName" required />
<bit-hint bitTypography="helper" class="tw-text-muted">
{{ "organizationNameDescription" | i18n }}
</bit-hint>
</bit-form-field>
<p bitTypography="helper" class="tw-text-muted tw-pt-1 tw-pl-1">
{{ "organizationNameDescription" | i18n }}
</p>
</div>
}
<div class="tw-pb-8 !tw-mx-0">

View File

@@ -0,0 +1,230 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
viewChild,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService, IconComponent } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared";
import { BitwardenSubscriber } from "../../types";
import { getCardBrandIcon, MaskedPaymentMethod, TokenizablePaymentMethods } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
/**
* Component for inline editing of payment methods.
* Displays a form to update payment method details directly within the parent view.
*/
@Component({
selector: "app-display-payment-method-inline",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<bit-section>
@if (!isChangingPayment()) {
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
<div class="tw-flex tw-items-center tw-gap-2">
@if (paymentMethod(); as pm) {
@switch (pm.type) {
@case ("bankAccount") {
@if (pm.hostedVerificationUrl) {
<p>
{{ "verifyBankAccountWithStripe" | i18n }}
<a
bitLink
rel="noreferrer"
target="_blank"
[attr.href]="pm.hostedVerificationUrl"
>{{ "verifyNow" | i18n }}</a
>
</p>
}
<p>
<bit-icon name="bwi-billing"></bit-icon>
{{ pm.bankName }}, *{{ pm.last4 }}
@if (pm.hostedVerificationUrl) {
<span>- {{ "unverified" | i18n }}</span>
}
</p>
}
@case ("card") {
<p class="tw-flex tw-gap-2">
@if (cardBrandIcon(); as icon) {
<i class="bwi bwi-fw credit-card-icon {{ icon }}"></i>
} @else {
<bit-icon name="bwi-credit-card"></bit-icon>
}
{{ pm.brand | titlecase }}, *{{ pm.last4 }},
{{ pm.expiration }}
</p>
}
@case ("payPal") {
<p>
<bit-icon name="bwi-paypal" class="tw-text-primary-600"></bit-icon>
{{ pm.email }}
</p>
}
}
} @else {
<p bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
}
@let key = paymentMethod() ? "changePaymentMethod" : "addPaymentMethod";
<a
bitLink
linkType="primary"
class="tw-cursor-pointer tw-mb-4"
(click)="changePaymentMethod()"
>
{{ key | i18n }}</a
>
</div>
} @else {
<app-enter-payment-method
#enterPaymentMethodComponent
[includeBillingAddress]="true"
[group]="formGroup"
[showBankAccount]="true"
[showAccountCredit]="false"
>
</app-enter-payment-method>
<div class="tw-mt-4 tw-flex tw-gap-2">
<button
bitLink
linkType="default"
type="button"
(click)="submit()"
[disabled]="formGroup.invalid"
>
{{ "save" | i18n }}
</button>
<button bitLink linkType="subtle" type="button" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
}
</bit-section>
`,
standalone: true,
imports: [SharedModule, EnterPaymentMethodComponent, IconComponent],
providers: [SubscriberBillingClient],
})
export class DisplayPaymentMethodInlineComponent {
readonly subscriber = input.required<BitwardenSubscriber>();
readonly paymentMethod = input.required<MaskedPaymentMethod | null>();
readonly updated = output<MaskedPaymentMethod>();
readonly changingStateChanged = output<boolean>();
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
private readonly enterPaymentMethodComponent = viewChild<EnterPaymentMethodComponent>(
EnterPaymentMethodComponent,
);
protected readonly isChangingPayment = signal(false);
protected readonly cardBrandIcon = computed(() => getCardBrandIcon(this.paymentMethod()));
private readonly billingClient = inject(SubscriberBillingClient);
private readonly i18nService = inject(I18nService);
private readonly toastService = inject(ToastService);
private readonly logService = inject(LogService);
/**
* Initiates the payment method change process by displaying the inline form.
*/
protected changePaymentMethod = async (): Promise<void> => {
this.isChangingPayment.set(true);
this.changingStateChanged.emit(true);
};
/**
* Submits the payment method update form.
* Validates the form, tokenizes the payment method, and sends the update request.
*/
protected submit = async (): Promise<void> => {
try {
if (!this.formGroup.valid) {
this.formGroup.markAllAsTouched();
throw new Error("Form is invalid");
}
const component = this.enterPaymentMethodComponent();
if (!component) {
throw new Error("Payment method component not found");
}
const paymentMethod = await component.tokenize();
if (!paymentMethod) {
throw new Error("Failed to tokenize payment method");
}
const billingAddress =
this.formGroup.value.type !== TokenizablePaymentMethods.payPal
? this.formGroup.controls.billingAddress.getRawValue()
: null;
await this.handlePaymentMethodUpdate(paymentMethod, billingAddress);
} catch (error) {
this.logService.error("Error submitting payment method update:", error);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("paymentMethodUpdateError"),
});
throw error;
}
};
/**
* Handles the payment method update API call and result processing.
*/
private async handlePaymentMethodUpdate(paymentMethod: any, billingAddress: any): Promise<void> {
const result = await this.billingClient.updatePaymentMethod(
this.subscriber(),
paymentMethod,
billingAddress,
);
switch (result.type) {
case "success": {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("paymentMethodUpdated"),
});
this.updated.emit(result.value);
this.isChangingPayment.set(false);
this.changingStateChanged.emit(false);
this.formGroup.reset();
break;
}
case "error": {
this.logService.error("Error submitting payment method update:", result);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("paymentMethodUpdateError"),
});
break;
}
}
}
/**
* Cancels the inline editing and resets the form.
*/
protected cancel = (): void => {
this.formGroup.reset();
this.changingStateChanged.emit(false);
this.isChangingPayment.set(false);
};
}

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Component, EventEmitter, input, Input, Output } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components";
@@ -15,7 +15,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial
selector: "app-display-payment-method",
template: `
<bit-section>
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
@if (!hideHeader()) {
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
}
@if (paymentMethod) {
@switch (paymentMethod.type) {
@case ("bankAccount") {
@@ -81,6 +83,7 @@ export class DisplayPaymentMethodComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() updated = new EventEmitter<MaskedPaymentMethod>();
protected readonly hideHeader = input<boolean>(false);
constructor(private dialogService: DialogService) {}

View File

@@ -2,6 +2,7 @@ export * from "./add-account-credit-dialog.component";
export * from "./change-payment-method-dialog.component";
export * from "./display-account-credit.component";
export * from "./display-billing-address.component";
export * from "./display-payment-method-inline.component";
export * from "./display-payment-method.component";
export * from "./edit-billing-address-dialog.component";
export * from "./enter-billing-address.component";

View File

@@ -260,9 +260,9 @@ export class VaultItemsComponent<C extends CipherViewLike> {
}
get isAllSelected() {
return this.editableItems
.slice(0, MaxSelectionCount)
.every((item) => this.selection.isSelected(item));
// Check selection against sorted items to match toggleAll() behavior
const sortedItems = this.getSortedEditableItems();
return sortedItems.slice(0, MaxSelectionCount).every((item) => this.selection.isSelected(item));
}
get isEmpty() {
@@ -376,9 +376,30 @@ export class VaultItemsComponent<C extends CipherViewLike> {
}
protected toggleAll() {
this.isAllSelected
? this.selection.clear()
: this.selection.select(...this.editableItems.slice(0, MaxSelectionCount));
if (this.isAllSelected) {
this.selection.clear();
} else {
const sortedItems = this.getSortedEditableItems();
this.selection.select(...sortedItems.slice(0, MaxSelectionCount));
}
}
/**
* Returns editableItems sorted according to the current table sort configuration.
* This ensures bulk selection matches the visual order displayed to the user.
*/
private getSortedEditableItems(): VaultItem<C>[] {
const currentSort = this.dataSource.sort;
const items = [...this.editableItems];
// If no sort function is set, return items in their original order (as displayed in table)
if (!currentSort || !currentSort.fn) {
return items;
}
// Apply sort function with direction modifier (matches TableDataSource.sortData behavior)
const directionModifier = currentSort.direction === "asc" ? 1 : -1;
return items.sort((a, b) => currentSort.fn(a, b, currentSort.direction) * directionModifier);
}
protected event(event: VaultItemEvent<C>) {
@@ -584,12 +605,13 @@ export class VaultItemsComponent<C extends CipherViewLike> {
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
*/
protected sortByName = (a: VaultItem<C>, b: VaultItem<C>, direction: SortDirection) => {
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
// Collections before ciphers (direction-independent)
const collectionCompare = this.prioritizeCollections(a, b);
if (collectionCompare !== 0) {
return collectionCompare;
}
// Name comparison (direction-dependent, handled by directionModifier)
return this.compareNames(a, b);
};
@@ -611,8 +633,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
return null;
};
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
// Collections before ciphers (direction-independent)
const collectionCompare = this.prioritizeCollections(a, b);
if (collectionCompare !== 0) {
return collectionCompare;
}
@@ -655,8 +677,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
return priorityMap[permission] ?? -1;
};
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
// Collections before ciphers (direction-independent)
const collectionCompare = this.prioritizeCollections(a, b);
if (collectionCompare !== 0) {
return collectionCompare;
}
@@ -664,11 +686,12 @@ export class VaultItemsComponent<C extends CipherViewLike> {
const priorityA = getPermissionPriority(a);
const priorityB = getPermissionPriority(b);
// Higher priority first
// Higher priority first (direction-dependent, handled by directionModifier)
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Fallback to name comparison (direction-dependent, handled by directionModifier)
return this.compareNames(a, b);
};
@@ -679,22 +702,19 @@ export class VaultItemsComponent<C extends CipherViewLike> {
/**
* Sorts VaultItems by prioritizing collections over ciphers.
* Collections are always placed before ciphers, regardless of the sorting direction.
* Always returns -1 for collections before ciphers, regardless of sort direction.
* This comparison is direction-independent; the direction is applied separately via directionModifier.
*/
private prioritizeCollections(
a: VaultItem<C>,
b: VaultItem<C>,
direction: SortDirection,
): number {
private prioritizeCollections(a: VaultItem<C>, b: VaultItem<C>): number {
if (a.collection && !b.collection) {
return direction === "asc" ? -1 : 1;
return -1; // a (collection) comes before b (cipher)
}
if (!a.collection && b.collection) {
return direction === "asc" ? 1 : -1;
return 1; // b (collection) comes before a (cipher)
}
return 0;
return 0; // Both are collections or both are ciphers
}
private hasPersonalItems(): boolean {

View File

@@ -9,7 +9,6 @@ import {
lastValueFrom,
Observable,
Subject,
zip,
} from "rxjs";
import {
concatMap,
@@ -35,7 +34,6 @@ import {
ItemTypes,
BitSvg,
} from "@bitwarden/assets/svg";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import {
@@ -60,9 +58,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -113,15 +109,9 @@ import {
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} 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";
import {
AutoConfirmPolicy,
AutoConfirmPolicyDialogComponent,
PolicyEditDialogResult,
} from "../../admin-console/organizations/policies";
import {
CollectionDialogAction,
CollectionDialogTabType,
@@ -138,6 +128,7 @@ import { VaultItem } from "../components/vault-items/vault-item";
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { VaultItemsComponent } from "../components/vault-items/vault-items.component";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
import { WebVaultPromptService } from "../services/web-vault-prompt.service";
import {
BulkDeleteDialogResult,
@@ -183,6 +174,7 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService,
WebVaultPromptService,
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
],
})
@@ -195,7 +187,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent<C>;
trashCleanupWarning: string = null;
kdfIterations: number;
activeFilter: VaultFilter = new VaultFilter();
protected deactivatedOrgIcon = DeactivatedOrg;
@@ -224,7 +215,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private destroy$ = new Subject<void>();
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private autoConfirmDialogRef?: DialogRef<PolicyEditDialogResult> | undefined;
protected showAddCipherBtn: boolean = false;
@@ -346,11 +336,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private cipherArchiveService: CipherArchiveService,
private organizationWarningsService: OrganizationWarningsService,
private policyService: PolicyService,
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
private autoConfirmService: AutomaticUserConfirmationService,
private configService: ConfigService,
private vaultItemTransferService: VaultItemsTransferService,
private webVaultPromptService: WebVaultPromptService,
) {}
async ngOnInit() {
@@ -646,11 +633,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.changeDetectorRef.markForCheck();
},
);
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
this.setupAutoConfirm();
void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
void this.webVaultPromptService.conditionallyPromptUser();
}
ngOnDestroy() {
@@ -1608,72 +1592,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
const cipherView = await this.cipherService.decrypt(_cipher, activeUserId);
return cipherView.login?.password;
}
private async openAutoConfirmFeatureDialog(organization: Organization) {
if (this.autoConfirmDialogRef) {
return;
}
this.autoConfirmDialogRef = AutoConfirmPolicyDialogComponent.open(this.dialogService, {
data: {
policy: new AutoConfirmPolicy(),
organizationId: organization.id,
firstTimeDialog: true,
},
});
await lastValueFrom(this.autoConfirmDialogRef.closed);
this.autoConfirmDialogRef = undefined;
}
private setupAutoConfirm() {
// if the policy is enabled, then the user may only belong to one organization at most.
const organization$ = this.organizations$.pipe(map((organizations) => organizations[0]));
const featureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm);
const autoConfirmState$ = this.userId$.pipe(
switchMap((userId) => this.autoConfirmService.configuration$(userId)),
);
const policyEnabled$ = combineLatest([
this.userId$.pipe(
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm && p.enabled)),
),
organization$,
]).pipe(
map(
([policy, organization]) => (policy && policy.organizationId === organization?.id) ?? false,
),
);
zip([organization$, featureFlag$, autoConfirmState$, policyEnabled$, this.userId$])
.pipe(
first(),
switchMap(async ([organization, flagEnabled, autoConfirmState, policyEnabled, userId]) => {
const showDialog =
flagEnabled &&
!policyEnabled &&
autoConfirmState.showSetupDialog &&
!!organization &&
organization.canEnableAutoConfirmPolicy;
if (showDialog) {
await this.openAutoConfirmFeatureDialog(organization);
await this.autoConfirmService.upsert(userId, {
...autoConfirmState,
showSetupDialog: false,
});
}
}),
takeUntil(this.destroy$),
)
.subscribe({
error: (err: unknown) => this.logService.error("Failed to update auto-confirm state", err),
});
}
}
/**

View File

@@ -0,0 +1,234 @@
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
import { BehaviorSubject, of } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { VaultItemsTransferService } from "@bitwarden/vault";
import {
AutoConfirmPolicyDialogComponent,
PolicyEditDialogResult,
} from "../../admin-console/organizations/policies";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
import { WebVaultPromptService } from "./web-vault-prompt.service";
describe("WebVaultPromptService", () => {
let service: WebVaultPromptService;
const mockUserId = "user-123" as UserId;
const mockOrganizationId = "org-456";
const getFeatureFlag$ = jest.fn().mockReturnValue(of(false));
const open = jest.fn();
const policies$ = jest.fn().mockReturnValue(of([]));
const configurationAutoConfirm$ = jest
.fn()
.mockReturnValue(
of({ showSetupDialog: false, enabled: false, showBrowserNotification: false }),
);
const upsertAutoConfirm = jest.fn().mockResolvedValue(undefined);
const organizations$ = jest.fn().mockReturnValue(of([]));
const displayUpgradePromptConditionally = jest.fn().mockResolvedValue(undefined);
const enforceOrganizationDataOwnership = jest.fn().mockResolvedValue(undefined);
const logError = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [
WebVaultPromptService,
{ provide: UnifiedUpgradePromptService, useValue: { displayUpgradePromptConditionally } },
{ provide: VaultItemsTransferService, useValue: { enforceOrganizationDataOwnership } },
{ provide: PolicyService, useValue: { policies$ } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: mockUserId }) } },
{
provide: AutomaticUserConfirmationService,
useValue: { configuration$: configurationAutoConfirm$, upsert: upsertAutoConfirm },
},
{ provide: OrganizationService, useValue: { organizations$ } },
{ provide: ConfigService, useValue: { getFeatureFlag$ } },
{ provide: DialogService, useValue: { open } },
{ provide: LogService, useValue: { error: logError } },
],
});
service = TestBed.inject(WebVaultPromptService);
});
describe("conditionallyPromptUser", () => {
it("calls displayUpgradePromptConditionally", async () => {
await service.conditionallyPromptUser();
expect(
service["unifiedUpgradePromptService"].displayUpgradePromptConditionally,
).toHaveBeenCalled();
});
it("calls enforceOrganizationDataOwnership with the userId", async () => {
await service.conditionallyPromptUser();
expect(
service["vaultItemTransferService"].enforceOrganizationDataOwnership,
).toHaveBeenCalledWith(mockUserId);
});
});
describe("setupAutoConfirm", () => {
it("shows dialog when all conditions are met", fakeAsync(() => {
getFeatureFlag$.mockReturnValueOnce(of(true));
configurationAutoConfirm$.mockReturnValueOnce(
of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
);
policies$.mockReturnValueOnce(of([]));
const mockOrg = {
id: mockOrganizationId,
canManagePolicies: true,
canEnableAutoConfirmPolicy: true,
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const dialogClosedSubject = new BehaviorSubject<PolicyEditDialogResult>(null);
const dialogRefMock = {
closed: dialogClosedSubject.asObservable(),
} as unknown as DialogRef<PolicyEditDialogResult>;
const openSpy = jest
.spyOn(AutoConfirmPolicyDialogComponent, "open")
.mockReturnValue(dialogRefMock);
void service.conditionallyPromptUser();
tick();
expect(openSpy).toHaveBeenCalledWith(expect.anything(), {
data: {
policy: expect.any(Object),
organizationId: mockOrganizationId,
firstTimeDialog: true,
},
});
dialogClosedSubject.next(null);
}));
it("does not show dialog when feature flag is disabled", fakeAsync(() => {
getFeatureFlag$.mockReturnValueOnce(of(false));
configurationAutoConfirm$.mockReturnValueOnce(
of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
);
policies$.mockReturnValueOnce(of([]));
const mockOrg = {
id: mockOrganizationId,
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
void service.conditionallyPromptUser();
tick();
expect(openSpy).not.toHaveBeenCalled();
}));
it("does not show dialog when policy is already enabled", fakeAsync(() => {
getFeatureFlag$.mockReturnValueOnce(of(true));
configurationAutoConfirm$.mockReturnValueOnce(
of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
);
const mockPolicy = {
type: PolicyType.AutoConfirm,
enabled: true,
} as Policy;
policies$.mockReturnValueOnce(of([mockPolicy]));
const mockOrg = {
id: mockOrganizationId,
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
void service.conditionallyPromptUser();
tick();
expect(openSpy).not.toHaveBeenCalled();
}));
it("does not show dialog when showSetupDialog is false", fakeAsync(() => {
getFeatureFlag$.mockReturnValueOnce(of(true));
configurationAutoConfirm$.mockReturnValueOnce(
of({ showSetupDialog: false, enabled: false, showBrowserNotification: false }),
);
policies$.mockReturnValueOnce(of([]));
const mockOrg = {
id: mockOrganizationId,
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
void service.conditionallyPromptUser();
tick();
expect(openSpy).not.toHaveBeenCalled();
}));
it("does not show dialog when organization is undefined", fakeAsync(() => {
getFeatureFlag$.mockReturnValueOnce(of(true));
configurationAutoConfirm$.mockReturnValueOnce(
of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
);
policies$.mockReturnValueOnce(of([]));
organizations$.mockReturnValueOnce(of([]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
void service.conditionallyPromptUser();
tick();
expect(openSpy).not.toHaveBeenCalled();
}));
it("does not show dialog when organization cannot enable auto-confirm policy", fakeAsync(() => {
getFeatureFlag$.mockReturnValueOnce(of(true));
configurationAutoConfirm$.mockReturnValueOnce(
of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
);
policies$.mockReturnValueOnce(of([]));
const mockOrg = {
id: mockOrganizationId,
canManagePolicies: false,
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
void service.conditionallyPromptUser();
tick();
expect(openSpy).not.toHaveBeenCalled();
}));
});
});

View File

@@ -0,0 +1,113 @@
import { inject, Injectable } from "@angular/core";
import { map, switchMap, combineLatest, zip, first, firstValueFrom } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { VaultItemsTransferService } from "@bitwarden/vault";
import {
AutoConfirmPolicyDialogComponent,
AutoConfirmPolicy,
} from "../../admin-console/organizations/policies";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
@Injectable()
export class WebVaultPromptService {
private unifiedUpgradePromptService = inject(UnifiedUpgradePromptService);
private vaultItemTransferService = inject(VaultItemsTransferService);
private policyService = inject(PolicyService);
private accountService = inject(AccountService);
private autoConfirmService = inject(AutomaticUserConfirmationService);
private organizationService = inject(OrganizationService);
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
private logService = inject(LogService);
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
private organizations$ = this.userId$.pipe(
switchMap((id) => this.organizationService.organizations$(id)),
);
/**
* Conditionally initiates prompts for users.
* All logic for users should be handled within this method to avoid
* the user seeing multiple onboarding prompts at different times.
*/
async conditionallyPromptUser() {
const userId = await firstValueFrom(this.userId$);
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
void this.vaultItemTransferService.enforceOrganizationDataOwnership(userId);
this.checkForAutoConfirm();
}
private async openAutoConfirmFeatureDialog(organization: Organization) {
AutoConfirmPolicyDialogComponent.open(this.dialogService, {
data: {
policy: new AutoConfirmPolicy(),
organizationId: organization.id,
firstTimeDialog: true,
},
});
}
private checkForAutoConfirm() {
// if the policy is enabled, then the user may only belong to one organization at most.
const organization$ = this.organizations$.pipe(map((organizations) => organizations[0]));
const featureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm);
const autoConfirmState$ = this.userId$.pipe(
switchMap((userId) => this.autoConfirmService.configuration$(userId)),
);
const policyEnabled$ = combineLatest([
this.userId$.pipe(
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm && p.enabled)),
),
organization$,
]).pipe(
map(
([policy, organization]) => (policy && policy.organizationId === organization?.id) ?? false,
),
);
zip([organization$, featureFlag$, autoConfirmState$, policyEnabled$, this.userId$])
.pipe(
first(),
switchMap(async ([organization, flagEnabled, autoConfirmState, policyEnabled, userId]) => {
const showDialog =
flagEnabled &&
!policyEnabled &&
autoConfirmState.showSetupDialog &&
!!organization &&
organization.canEnableAutoConfirmPolicy;
if (showDialog) {
await this.openAutoConfirmFeatureDialog(organization);
await this.autoConfirmService.upsert(userId, {
...autoConfirmState,
showSetupDialog: false,
});
}
}),
)
.subscribe({
error: (err: unknown) => this.logService.error("Failed to update auto-confirm state", err),
});
}
}

View File

@@ -5394,8 +5394,8 @@
"minimumNumberOfWords": {
"message": "Minimum number of words"
},
"overridePasswordTypePolicy": {
"message": "Password Type",
"passwordTypePolicyOverride": {
"message": "Password type",
"description": "Name of the password generator policy that overrides the user's password/passphrase selection."
},
"userPreference": {
@@ -12794,5 +12794,54 @@
},
"perUser": {
"message": "per user"
},
"upgradeToTeams": {
"message": "Upgrade to Teams"
},
"upgradeToEnterprise": {
"message": "Upgrade to Enterprise"
},
"upgradeShareEvenMore": {
"message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise"
},
"organizationUpgradeTaxInformationMessage": {
"message": "Prices exclude tax and are billed annually."
},
"invoicePreviewErrorMessage": {
"message": "Encountered an error while generating the invoice preview."
},
"planProratedMembershipInMonths": {
"message": "Prorated $PLAN$ membership ($NUMOFMONTHS$)",
"placeholders": {
"plan": {
"content": "$1",
"example": "Families"
},
"numofmonths": {
"content": "$2",
"example": "6 Months"
}
}
},
"premiumSubscriptionCredit": {
"message": "Premium subscription credit"
},
"enterpriseMembership": {
"message": "Enterprise membership"
},
"teamsMembership": {
"message": "Teams membership"
},
"plansUpdated": {
"message": "You've upgraded to $PLAN$!",
"placeholders": {
"plan": {
"content": "$1",
"example": "Families"
}
}
},
"paymentMethodUpdateError": {
"message": "There was an error updating your payment method."
}
}

View File

@@ -1,10 +1,13 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BitwardenIcon, ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
price?: {
amount: number;
cadence: "month" | "monthly" | "year" | "annually";
showPerUser?: boolean;
};
button: {
text: string;
type: ButtonType;

View File

@@ -1,6 +1,6 @@
@let cart = this.cart();
@let term = this.term();
@let hideTerm = this.hidePricingTerm();
<div class="tw-size-full">
<div class="tw-flex tw-items-center tw-pb-2">
<div class="tw-flex tw-items-center">
@@ -16,7 +16,9 @@
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
</h2>
<span bitTypography="h3">&nbsp;</span>
<span bitTypography="body1" class="tw-text-main tw-font-normal">/ {{ term }}</span>
@if (!hideTerm) {
<span bitTypography="body1" class="tw-text-muted tw-ms-2"> / {{ term | i18n }} </span>
}
}
</div>
<button
@@ -58,8 +60,10 @@
@if (!passwordManagerSeats.hideBreakdown) {
x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
@if (!hideTerm) {
/
{{ term }}
}
}
</div>
</div>
@@ -86,8 +90,11 @@
)
}}
@if (!additionalStorage.hideBreakdown) {
x {{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
x {{ additionalStorage.cost | currency: "USD" : "symbol" }}
@if (!hideTerm) {
/
{{ term }}
}
}
</div>
</div>
@@ -125,7 +132,10 @@
@if (!secretsManagerSeats.hideBreakdown) {
x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
@if (!hideTerm) {
/
{{ term }}
}
}
</div>
<div
@@ -152,8 +162,10 @@
@if (!additionalServiceAccounts.hideBreakdown) {
x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
@if (!hideTerm) {
/
{{ term }}
}
}
</div>
<div
@@ -219,7 +231,10 @@
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">{{ "total" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
{{ total() | currency: "USD" : "symbol" }}
@if (!hidePricingTerm()) {
/ {{ term | i18n }}
}
</div>
</div>
</div>

View File

@@ -29,6 +29,8 @@ behavior across Bitwarden applications.
- [With Percent Discount](#with-percent-discount)
- [With Amount Discount](#with-amount-discount)
- [With Discount and Credit](#with-discount-and-credit)
- [Hidden Cost Breakdown](#hidden-cost-breakdown)
- [Hidden Pricing Term](#hidden-pricing-term)
- [Custom Header Template](#custom-header-template)
- [Premium Plan](#premium-plan)
- [Families Plan](#families-plan)
@@ -53,10 +55,16 @@ import { CartSummaryComponent, Cart } from "@bitwarden/pricing";
### Inputs
| Input | Type | Description |
| -------- | ------------------------ | ------------------------------------------------------------------------------- |
| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence |
| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header |
| Input | Type | Description |
| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------- |
| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence |
| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header |
| `hidePricingTerm` | `boolean` | **Optional.** When true, hides the billing term (e.g., "/ month", "/ year") from the header |
**Note:** Individual `CartItem` objects in the cart can include:
- `hideBreakdown` (boolean): Hides the cost breakdown (quantity × unit price) for that specific line
item
### Events
@@ -73,6 +81,7 @@ export type CartItem = {
quantity: number; // Number of items
cost: number; // Cost per item
discount?: Discount; // Optional item-level discount
hideBreakdown?: boolean; // Optional: hide cost breakdown (quantity × unit price)
};
export type Cart = {
@@ -468,6 +477,74 @@ Show cart with both discount and credit applied:
</billing-cart-summary>
```
### Hidden Cost Breakdown
Show cart with hidden cost breakdowns (hides quantity × unit price for line items):
<Canvas of={CartSummaryStories.WithHiddenBreakdown} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00,
hideBreakdown: true
},
additionalStorage: {
quantity: 2,
translationKey: 'additionalStorageGB',
cost: 10.00,
hideBreakdown: true
}
},
secretsManager: {
seats: {
quantity: 3,
translationKey: 'members',
cost: 30.00,
hideBreakdown: true
},
additionalServiceAccounts: {
quantity: 2,
translationKey: 'additionalServiceAccountsV2',
cost: 6.00,
hideBreakdown: true
}
},
cadence: 'monthly',
estimatedTax: 19.2
}"
>
</billing-cart-summary>
```
### Hidden Pricing Term
Show cart with hidden pricing term (hides "/ month" or "/ year" from header):
<Canvas of={CartSummaryStories.HiddenPricingTerm} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
}
},
cadence: 'monthly',
estimatedTax: 9.6
}"
[hidePricingTerm]="true"
>
</billing-cart-summary>
```
### Custom Header Template
Show cart with custom header template:
@@ -546,6 +623,10 @@ Show cart with families plan:
keys
- **Custom Header Templates**: Optional header input allows for custom header designs while
maintaining cart functionality
- **Hidden Cost Breakdown**: Individual cart items can hide their cost breakdown (quantity × unit
price) using the `hideBreakdown` property
- **Hidden Pricing Term**: Component can hide the billing term ("/ month" or "/ year") from the
header using the `hidePricingTerm` input
- **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts
- **Consistent Formatting**: Maintains uniform display of prices, quantities, and cadence
- **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values, OnPush
@@ -561,6 +642,9 @@ Show cart with families plan:
- Use valid translation keys for CartItem translationKey (for i18n lookup)
- Provide complete Cart object with all required fields
- Use "annually" or "monthly" for cadence (not "year" or "month")
- Use `hideBreakdown` on individual cart items when you want to hide cost breakdowns
- Use the `hidePricingTerm` component input when the billing term shouldn't be displayed in the
header
### ❌ Don't

View File

@@ -192,7 +192,7 @@ describe("CartSummaryComponent", () => {
it("should display correct secrets manager information", () => {
// Arrange
const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]'));
const smHeading = smSection.query(By.css("h3"));
const smHeading = smSection?.query(By.css('div[bitTypography="h5"]'));
const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]'))
.nativeElement.textContent;
const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]'))
@@ -200,7 +200,8 @@ describe("CartSummaryComponent", () => {
// Act/ Assert
expect(smSection).toBeTruthy();
expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager");
expect(smHeading).toBeTruthy();
expect(smHeading!.nativeElement.textContent.trim()).toBe("Secrets Manager");
// Check seats line item
expect(sectionText).toContain("3 Secrets Manager seats");
@@ -245,7 +246,7 @@ describe("CartSummaryComponent", () => {
it("should display term (month/year) in default header", () => {
// Arrange / Act
const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-main"));
const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted"));
// Find the span that contains the term
const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/"));
@@ -253,6 +254,42 @@ describe("CartSummaryComponent", () => {
expect(termElement).toBeTruthy();
expect(termElement!.nativeElement.textContent.trim()).toBe("/ month");
});
it("should hide term when hidePricingTerm is true", () => {
// Arrange
const cartWithHiddenTerm: Cart = {
...mockCart,
};
fixture.componentRef.setInput("cart", cartWithHiddenTerm);
fixture.componentRef.setInput("hidePricingTerm", true);
fixture.detectChanges();
// Act
const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted"));
const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/"));
// Assert
expect(component.hidePricingTerm()).toBe(true);
expect(termElement).toBeFalsy();
});
it("should show term when hidePricingTerm is false", () => {
// Arrange
const cartWithVisibleTerm: Cart = {
...mockCart,
};
fixture.componentRef.setInput("cart", cartWithVisibleTerm);
fixture.detectChanges();
// Act
const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted"));
const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/"));
// Assert
expect(component.hidePricingTerm()).toBe(false);
expect(termElement).toBeTruthy();
expect(termElement!.nativeElement.textContent).toContain("/ month");
});
});
describe("hideBreakdown Property", () => {
@@ -287,7 +324,7 @@ describe("CartSummaryComponent", () => {
);
// Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month");
expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month");
});
it("should hide cost breakdown for additional storage when hideBreakdown is true", () => {
@@ -401,7 +438,7 @@ describe("CartSummaryComponent", () => {
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const discountLabel = discountSection.query(By.css("h3"));
const discountLabel = discountSection.query(By.css("div.tw-text-success-600"));
const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]'));
// Act / Assert
@@ -426,7 +463,7 @@ describe("CartSummaryComponent", () => {
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const discountLabel = discountSection.query(By.css("h3"));
const discountLabel = discountSection.query(By.css("div.tw-text-success-600"));
const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]'));
// Act / Assert
@@ -481,7 +518,7 @@ describe("CartSummaryComponent", () => {
fixture.detectChanges();
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const creditLabel = creditSection.query(By.css("h3"));
const creditLabel = creditSection.query(By.css('div[bitTypography="body1"]'));
const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]'));
// Act / Assert

View File

@@ -432,3 +432,21 @@ export const WithDiscountAndCredit: Story = {
} satisfies Cart,
},
};
export const HiddenPricingTerm: Story = {
name: "Hidden Pricing Term",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
},
cadence: "monthly",
estimatedTax: 9.6,
} satisfies Cart,
hidePricingTerm: true,
},
};

View File

@@ -37,6 +37,9 @@ export class CartSummaryComponent {
// Optional inputs
readonly header = input<TemplateRef<{ total: number }>>();
// Hide pricing term (e.g., "/ month" or "/ year") if true
readonly hidePricingTerm = input<boolean>(false);
// UI state
readonly isExpanded = signal(true);