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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<billing-subscription-card
|
||||
[title]="'premiumMembership' | i18n"
|
||||
[subscription]="subscription"
|
||||
[showUpgradeButton]="premiumToOrganizationUpgradeEnabled()"
|
||||
[showUpgradeButton]="canUpgradeFromPremium()"
|
||||
(callToActionClicked)="onSubscriptionCardAction($event)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
234
apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
Normal file
234
apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
113
apps/web/src/app/vault/services/web-vault-prompt.service.ts
Normal file
113
apps/web/src/app/vault/services/web-vault-prompt.service.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"> </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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user