1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build

This commit is contained in:
John Harrington
2025-12-09 07:50:37 -07:00
committed by GitHub
19 changed files with 487 additions and 321 deletions

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- name: 'Run stale action'
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
stale-issue-label: 'needs-reply'
stale-pr-label: 'needs-changes'

View File

@@ -1675,9 +1675,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "libloading"
@@ -2877,9 +2877,9 @@ dependencies = [
[[package]]
name = "security-framework"
version = "3.5.0"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags",
"core-foundation",

View File

@@ -39,7 +39,7 @@ futures = "=0.3.31"
hex = "=0.4.3"
homedir = "=0.3.4"
interprocess = "=2.2.1"
libc = "=0.2.177"
libc = "=0.2.178"
linux-keyutils = "=0.2.4"
memsec = "=0.7.0"
napi = "=2.16.17"
@@ -53,7 +53,7 @@ rsa = "=0.9.6"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
secmem-proc = "=0.3.7"
security-framework = "=3.5.0"
security-framework = "=3.5.1"
security-framework-sys = "=2.15.0"
serde = "=1.0.209"
serde_json = "=1.0.127"

View File

@@ -37,6 +37,6 @@ concurrently(
{
prefix: "name",
outputStream: process.stdout,
killOthers: ["success", "failure"],
killOthersOn: ["success", "failure"],
},
);

View File

@@ -34,6 +34,6 @@ concurrently(
{
prefix: "name",
outputStream: process.stdout,
killOthers: ["success", "failure"],
killOthersOn: ["success", "failure"],
},
);

View File

@@ -157,7 +157,7 @@ export class CloudHostedPremiumVNextComponent {
return {
tier,
price:
tier?.passwordManager.type === "standalone"
tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
@@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent {
return {
tier,
price:
tier?.passwordManager.type === "packaged"
tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],

View File

@@ -1,15 +1,15 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
import { Component, computed, DestroyRef, input, OnInit, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { 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 {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -32,14 +32,6 @@ export type UpgradeAccountResult = {
plan: PersonalSubscriptionPricingTierId | null;
};
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType };
features: string[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
protected readonly loading = signal(true);
protected premiumCardDetails!: CardDetails;
protected familiesCardDetails!: CardDetails;
protected premiumCardDetails!: SubscriptionPricingCardDetails;
protected familiesCardDetails!: SubscriptionPricingCardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
@@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit {
private createCardDetails(
tier: PersonalSubscriptionPricingTier,
buttonType: ButtonType,
): CardDetails {
): SubscriptionPricingCardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
price: tier.passwordManager.annualPrice
? {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: {
text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",

View File

@@ -200,7 +200,8 @@ export class UpgradePaymentService {
}
private getPasswordManagerSeats(planDetails: PlanDetails): number {
return "users" in planDetails.details.passwordManager
return "users" in planDetails.details.passwordManager &&
planDetails.details.passwordManager.users
? planDetails.details.passwordManager.users
: 0;
}

View File

@@ -20,33 +20,35 @@
<div
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
>
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
<div class="tw-flex tw-items-center tw-justify-between">
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ "upgradeToPremium" | i18n }}
</h3>
</div>
<!-- Tagline with consistent height (exactly 2 lines) -->
<div class="tw-mb-6 tw-h-6">
<div class="tw-h-6">
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
{{ cardDetails.tagline }}
</p>
</div>
<!-- Price Section -->
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence | i18n }}
</span>
@if (cardDetails.price) {
<div class="tw-mt-5">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence | i18n }}
</span>
</div>
</div>
</div>
}
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
<div class="tw-my-5 tw-h-12">
<button
bitButton
[buttonType]="cardDetails.button.type"

View File

@@ -206,4 +206,39 @@ describe("PremiumUpgradeDialogComponent", () => {
});
});
});
describe("self-hosted environment", () => {
it("should handle null price data for self-hosted environment", async () => {
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: undefined as any, // self-host will have these prices empty
annualPricePerAdditionalStorageGB: undefined as any,
providedStorageGB: undefined as any,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
};
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([selfHostedPremiumTier]),
);
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const selfHostedComponent = selfHostedFixture.componentInstance;
selfHostedFixture.detectChanges();
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.price).toBeUndefined();
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
});
});
});

View File

@@ -42,6 +42,23 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
},
};
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Complete online security",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
features: [
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
{ key: "secureFileStorage", value: "Secure file storage" },
{ key: "emergencyAccess", value: "Emergency access" },
{ key: "breachMonitoring", value: "Breach monitoring" },
{ key: "andMoreFeatures", value: "And more!" },
],
},
};
export default {
title: "Billing/Premium Upgrade Dialog",
component: PremiumUpgradeDialogComponent,
@@ -86,11 +103,11 @@ export default {
t: (key: string) => {
switch (key) {
case "upgradeNow":
return "Upgrade Now";
return "Upgrade now";
case "month":
return "month";
case "upgradeToPremium":
return "Upgrade To Premium";
return "Upgrade to Premium";
default:
return key;
}
@@ -116,3 +133,18 @@ export default {
type Story = StoryObj<PremiumUpgradeDialogComponent>;
export const Default: Story = {};
export const NoPricingData: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: SubscriptionPricingServiceAbstraction,
useValue: {
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
},
},
],
}),
],
};

View File

@@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonModule,
ButtonType,
CenterPositionStrategy,
DialogModule,
DialogRef,
@@ -27,14 +26,6 @@ import {
} from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};
@Component({
selector: "billing-premium-upgrade-dialog",
standalone: true,
@@ -51,9 +42,8 @@ type CardDetails = {
templateUrl: "./premium-upgrade-dialog.component.html",
})
export class PremiumUpgradeDialogComponent {
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(
protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => {
@@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
this.dialogRef.close();
}
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
private mapPremiumTierToCardDetails(
tier: PersonalSubscriptionPricingTier,
): SubscriptionPricingCardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
price: tier.passwordManager.annualPrice
? {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: {
text: this.i18nService.t("upgradeNow"),
type: "primary",

View File

@@ -0,0 +1,10 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};

View File

@@ -1498,7 +1498,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: SubscriptionPricingServiceAbstraction,
useClass: DefaultSubscriptionPricingService,
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
deps: [
BillingApiServiceAbstraction,
ConfigService,
I18nServiceAbstraction,
LogService,
EnvironmentService,
],
}),
safeProvider({
provide: OrganizationManagementPreferencesService,

View File

@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let environmentService: MockProxy<EnvironmentService>;
const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually2025,
@@ -328,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
});
});
const setupEnvironmentService = (
envService: MockProxy<EnvironmentService>,
region: Region = Region.US,
) => {
envService.environment$ = of({
getRegion: () => region,
isCloud: () => region !== Region.SelfHosted,
} as any);
};
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
environmentService = mock<EnvironmentService>();
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
setupEnvironmentService(environmentService);
service = new DefaultSubscriptionPricingService(
billingApiService,
configService,
i18nService,
logService,
environmentService,
);
});
@@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
@@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
@@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
setupEnvironmentService(errorEnvironmentService);
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
errorEnvironmentService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -914,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
},
});
});
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response missing the Seat property
const malformedResponse = {
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response with price as string instead of number
const malformedResponse = {
Seat: {
StripePriceId: "price_seat",
Price: "10", // Should be a number
},
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
});
describe("Observable behavior and caching", () => {
@@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(newEnvironmentService);
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
@@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe to the premium pricing tier multiple times
@@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({
@@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
storage: { price: 999 },
} as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(newEnvironmentService);
// Create a new service instance with the feature flag disabled
const newService = new DefaultSubscriptionPricingService(
@@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe with feature flag disabled
@@ -1071,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
});
});
});
describe("Self-hosted environment behavior", () => {
it("should not call API for self-hosted environment", () => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
// Trigger subscriptions by calling the methods
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
// API should not be called for self-hosted environments
expect(getPlansSpy).not.toHaveBeenCalled();
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
});
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
expect(tiers).toHaveLength(2); // Premium and Families
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
expect(premiumTier).toBeDefined();
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.features).toBeDefined();
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
done();
});
});
});
});

View File

@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
@@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private environmentService: EnvironmentService,
) {}
/**
* Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
@@ -66,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/**
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
@@ -80,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/**
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request.
*/
@@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
}),
);
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
this.environmentService.environment$.pipe(
take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
: from(this.billingApiService.getPlans()),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
this.billingApiService.getPremiumPlan(),
).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
this.environmentService.environment$.pipe(
take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
: from(this.billingApiService.getPremiumPlan()).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
@@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
provided: premiumPlan.storage.provided,
seat: premiumPlan.seat?.price,
storage: premiumPlan.storage?.price,
provided: premiumPlan.storage?.provided,
})),
)
: of({
@@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
)!;
private families$: Observable<PersonalSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
);
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan.PasswordManager.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan?.PasswordManager?.baseSeats,
annualPrice: familiesPlan?.PasswordManager?.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
return {
id: BusinessSubscriptionPricingTierIds.Free,
@@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
passwordManager: {
type: "free",
features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
this.featureTranslations.limitedCollectionsV2(
freePlan?.PasswordManager?.maxCollections,
),
this.featureTranslations.alwaysFree(),
],
},
@@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
type: "free",
features: [
this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
],
},
};
}),
);
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
private teams$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
)!;
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
),
);
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
);
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}),
),
);
private featureTranslations = {
builtInAuthenticator: () => ({
@@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"),
}),
limitedUsersV2: (users: number) => ({
limitedUsersV2: (users?: number) => ({
key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users),
}),
limitedCollectionsV2: (collections: number) => ({
limitedCollectionsV2: (collections?: number) => ({
key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections),
}),
@@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"),
}),
projectsIncludedV2: (projects: number) => ({
projectsIncludedV2: (projects?: number) => ({
key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects),
}),
@@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"),
}),
includedMachineAccountsV2: (included: number) => ({
includedMachineAccountsV2: (included?: number) => ({
key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included),
}),

View File

@@ -27,26 +27,26 @@ type HasFeatures = {
};
type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number;
annualPricePerAdditionalStorageGB?: number;
};
type HasProvidedStorage = {
providedStorageGB: number;
providedStorageGB?: number;
};
type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage &
HasProvidedStorage & {
type: "standalone";
annualPrice: number;
annualPrice?: number;
};
type PackagedPasswordManager = HasFeatures &
HasProvidedStorage &
HasAdditionalStorage & {
type: "packaged";
users: number;
annualPrice: number;
users?: number;
annualPrice?: number;
};
type FreePasswordManager = HasFeatures & {
@@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
HasProvidedStorage &
HasAdditionalStorage & {
type: "scalable";
annualPricePerUser: number;
annualPricePerUser?: number;
};
type FreeSecretsManager = HasFeatures & {
@@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
type ScalableSecretsManager = HasFeatures & {
type: "scalable";
annualPricePerUser: number;
annualPricePerAdditionalServiceAccount: number;
annualPricePerUser?: number;
annualPricePerAdditionalServiceAccount?: number;
};
export type PersonalSubscriptionPricingTier = {

86
package-lock.json generated
View File

@@ -121,12 +121,12 @@
"@webcomponents/custom-elements": "1.6.0",
"@yao-pkg/pkg": "6.5.1",
"angular-eslint": "20.7.0",
"autoprefixer": "10.4.21",
"autoprefixer": "10.4.22",
"axe-playwright": "2.2.2",
"babel-loader": "9.2.1",
"base64-loader": "1.0.0",
"browserslist": "4.28.1",
"chromatic": "13.3.1",
"chromatic": "13.3.4",
"concurrently": "9.2.0",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
@@ -169,7 +169,7 @@
"sass-loader": "16.0.6",
"storybook": "9.1.16",
"style-loader": "4.0.0",
"tailwindcss": "3.4.17",
"tailwindcss": "3.4.18",
"ts-jest": "29.4.5",
"ts-loader": "9.5.4",
"tsconfig-paths-webpack-plugin": "4.2.0",
@@ -1042,6 +1042,44 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/babel-loader": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz",
@@ -16631,9 +16669,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
"funding": [
{
"type": "opencollective",
@@ -16650,9 +16688,9 @@
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"browserslist": "^4.27.0",
"caniuse-lite": "^1.0.30001754",
"fraction.js": "^5.3.4",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
@@ -16667,6 +16705,19 @@
"postcss": "^8.1.0"
}
},
"node_modules/autoprefixer/node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -18214,9 +18265,9 @@
}
},
"node_modules/chromatic": {
"version": "13.3.1",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.1.tgz",
"integrity": "sha512-qJ/el70Wo7jFgiXPpuukqxCEc7IKiH/e8MjTzIF9uKw+3XZ6GghOTTLC7lGfeZtosiQBMkRlYet77tC4KKHUng==",
"version": "13.3.4",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.4.tgz",
"integrity": "sha512-TR5rvyH0ESXobBB3bV8jc87AEAFQC7/n+Eb4XWhJz6hW3YNxIQPVjcbgLv+a4oKHEl1dUBueWSoIQsOVGTd+RQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -22947,6 +22998,7 @@
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -38234,9 +38286,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"version": "3.4.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -38248,7 +38300,7 @@
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.6",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
@@ -38257,7 +38309,7 @@
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",

View File

@@ -83,12 +83,12 @@
"@webcomponents/custom-elements": "1.6.0",
"@yao-pkg/pkg": "6.5.1",
"angular-eslint": "20.7.0",
"autoprefixer": "10.4.21",
"autoprefixer": "10.4.22",
"axe-playwright": "2.2.2",
"babel-loader": "9.2.1",
"base64-loader": "1.0.0",
"browserslist": "4.28.1",
"chromatic": "13.3.1",
"chromatic": "13.3.4",
"concurrently": "9.2.0",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
@@ -131,7 +131,7 @@
"sass-loader": "16.0.6",
"storybook": "9.1.16",
"style-loader": "4.0.0",
"tailwindcss": "3.4.17",
"tailwindcss": "3.4.18",
"ts-jest": "29.4.5",
"ts-loader": "9.5.4",
"tsconfig-paths-webpack-plugin": "4.2.0",