mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 00:23:17 +00:00
Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export * from "./premium.component";
|
||||
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";
|
||||
|
||||
@@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
selector: "app-premium-badge",
|
||||
standalone: true,
|
||||
template: `
|
||||
<button type="button" *appNotPremium bitBadge variant="success" (click)="promptForPremium()">
|
||||
<button
|
||||
type="button"
|
||||
*appNotPremium
|
||||
bitBadge
|
||||
variant="success"
|
||||
(click)="promptForPremium($event)"
|
||||
>
|
||||
{{ "premium" | i18n }}
|
||||
</button>
|
||||
`,
|
||||
@@ -21,7 +27,9 @@ export class PremiumBadgeComponent {
|
||||
|
||||
constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {}
|
||||
|
||||
async promptForPremium() {
|
||||
async promptForPremium(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { BadgeModule, I18nMockService } from "@bitwarden/components";
|
||||
|
||||
import { PremiumBadgeComponent } from "./premium-badge.component";
|
||||
|
||||
class MockMessagingService implements MessageSender {
|
||||
send = () => {
|
||||
alert("Clicked on badge");
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Badge",
|
||||
component: PremiumBadgeComponent,
|
||||
@@ -40,12 +33,6 @@ export default {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MessageSender,
|
||||
useFactory: () => {
|
||||
return new MockMessagingService();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@if (cardDetails$ | async; as cardDetails) {
|
||||
<section
|
||||
class="tw-min-w-[332px] md:tw-max-w-sm 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 !tw-bg-background !tw-border-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
[label]="'close' | i18n"
|
||||
(click)="close()"
|
||||
></button>
|
||||
</header>
|
||||
<div class="tw-flex tw-justify-center tw-mb-6">
|
||||
<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">
|
||||
<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">
|
||||
<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-bold tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
[block]="true"
|
||||
(click)="upgrade()"
|
||||
type="button"
|
||||
>
|
||||
@if (cardDetails.button.icon?.position === "before") {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ cardDetails.button.text }}
|
||||
@if (
|
||||
cardDetails.button.icon &&
|
||||
(cardDetails.button.icon.position === "after" || !cardDetails.button.icon.position)
|
||||
) {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-ms-2" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="tw-flex-grow">
|
||||
@if (cardDetails.features.length > 0) {
|
||||
<ul class="tw-list-none tw-p-0 tw-m-0">
|
||||
@for (feature of cardDetails.features; track feature) {
|
||||
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
|
||||
<i
|
||||
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
|
||||
feature
|
||||
}}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { firstValueFrom, of, throwError } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
|
||||
|
||||
describe("PremiumUpgradeDialogComponent", () => {
|
||||
let component: PremiumUpgradeDialogComponent;
|
||||
let fixture: ComponentFixture<PremiumUpgradeDialogComponent>;
|
||||
let mockDialogRef: jest.Mocked<DialogRef>;
|
||||
let mockSubscriptionPricingService: jest.Mocked<SubscriptionPricingServiceAbstraction>;
|
||||
let mockI18nService: jest.Mocked<I18nService>;
|
||||
let mockToastService: jest.Mocked<ToastService>;
|
||||
let mockEnvironmentService: jest.Mocked<EnvironmentService>;
|
||||
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let mockLogService: jest.Mocked<LogService>;
|
||||
|
||||
const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Advanced features for power users",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature 1" },
|
||||
{ key: "feature2", value: "Feature 2" },
|
||||
{ key: "feature3", value: "Feature 3" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockFamiliesTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: "Families",
|
||||
description: "Family plan",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: 6,
|
||||
annualPrice: 40,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [{ key: "featureA", value: "Feature A" }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDialogRef = {
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockSubscriptionPricingService = {
|
||||
getPersonalSubscriptionPricingTiers$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockI18nService = {
|
||||
t: jest.fn((key: string) => key),
|
||||
} as any;
|
||||
|
||||
mockToastService = {
|
||||
showToast: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockEnvironmentService = {
|
||||
environment$: of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.com",
|
||||
getRegion: () => Region.US,
|
||||
}),
|
||||
} as any;
|
||||
|
||||
mockPlatformUtilsService = {
|
||||
launchUri: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockPremiumTier, mockFamiliesTier]),
|
||||
);
|
||||
|
||||
mockLogService = {
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should emit cardDetails$ observable with Premium tier data", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled();
|
||||
expect(cardDetails).toBeDefined();
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
});
|
||||
|
||||
it("should filter to Premium tier only", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.title).not.toBe("Families");
|
||||
});
|
||||
|
||||
it("should map Premium tier to card details correctly", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.tagline).toBe("Advanced features for power users");
|
||||
expect(cardDetails?.price.amount).toBe(10 / 12);
|
||||
expect(cardDetails?.price.cadence).toBe("monthly");
|
||||
expect(cardDetails?.button.text).toBe("upgradeNow");
|
||||
expect(cardDetails?.button.type).toBe("primary");
|
||||
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]);
|
||||
});
|
||||
|
||||
it("should use i18nService for button text", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow");
|
||||
expect(cardDetails?.button.text).toBe("upgradeNow");
|
||||
});
|
||||
|
||||
describe("upgrade()", () => {
|
||||
it("should launch URI with query parameter for cloud-hosted environments", async () => {
|
||||
mockEnvironmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.com",
|
||||
getRegion: () => Region.US,
|
||||
} as any);
|
||||
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should launch URI without query parameter for self-hosted environments", async () => {
|
||||
mockEnvironmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://self-hosted.example.com",
|
||||
getRegion: () => Region.SelfHosted,
|
||||
} as any);
|
||||
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://self-hosted.example.com/#/settings/subscription/premium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should launch URI with query parameter for EU cloud region", async () => {
|
||||
mockEnvironmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.eu",
|
||||
getRegion: () => Region.EU,
|
||||
} as any);
|
||||
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close dialog when close button clicked", () => {
|
||||
component["close"]();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => {
|
||||
const error = new Error("Service error");
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
throwError(() => error),
|
||||
);
|
||||
|
||||
const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
const errorComponent = errorFixture.componentInstance;
|
||||
errorFixture.detectChanges();
|
||||
|
||||
const cardDetails$ = errorComponent["cardDetails$"];
|
||||
|
||||
cardDetails$.subscribe({
|
||||
next: () => {
|
||||
done.fail("Observable should not emit any values");
|
||||
},
|
||||
complete: () => {
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "error",
|
||||
message: "unexpectedError",
|
||||
});
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
error: (err: unknown) => done.fail(`Observable should not error: ${err}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
ToastOptions,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
|
||||
|
||||
const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Complete online security",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
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,
|
||||
description: "A dialog for upgrading to Premium subscription",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [DialogModule, ButtonModule, TypographyModule],
|
||||
providers: [
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: {
|
||||
close: () => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: {
|
||||
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
showToast: (options: ToastOptions) => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
cloudWebVaultUrl$: of("https://vault.bitwarden.com"),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
launchUri: (uri: string) => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "upgradeNow":
|
||||
return "Upgrade Now";
|
||||
case "month":
|
||||
return "month";
|
||||
case "upgradeToPremium":
|
||||
return "Upgrade To Premium";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
error: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1",
|
||||
},
|
||||
},
|
||||
} as Meta<PremiumUpgradeDialogComponent>;
|
||||
|
||||
type Story = StoryObj<PremiumUpgradeDialogComponent>;
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
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,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} 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,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
TypographyModule,
|
||||
CdkTrapFocus,
|
||||
JslibModule,
|
||||
],
|
||||
templateUrl: "./premium-upgrade-dialog.component.html",
|
||||
})
|
||||
export class PremiumUpgradeDialogComponent {
|
||||
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
|
||||
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.logService.error("Error fetching and mapping pricing tiers", error);
|
||||
this.dialogRef.close();
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private dialogRef: DialogRef,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
protected async upgrade(): Promise<void> {
|
||||
const environment = await firstValueFrom(this.environmentService.environment$);
|
||||
let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium";
|
||||
if (environment.getRegion() !== Region.SelfHosted) {
|
||||
vaultUrl += "?callToAction=upgradeToPremium";
|
||||
}
|
||||
this.platformUtilsService.launchUri(vaultUrl);
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
button: {
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
type: "primary",
|
||||
icon: { type: "bwi-external-link", position: "after" },
|
||||
},
|
||||
features: tier.passwordManager.features.map((f) => f.value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the premium upgrade dialog.
|
||||
*
|
||||
* @param dialogService - The dialog service used to open the component
|
||||
* @returns A dialog reference object
|
||||
*/
|
||||
static open(dialogService: DialogService): DialogRef<PremiumUpgradeDialogComponent> {
|
||||
return dialogService.open(PremiumUpgradeDialogComponent);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit {
|
||||
private templateRef: TemplateRef<any>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
@@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const premium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
this.billingAccountProfileStateService
|
||||
.hasPremiumFromAnySource$(account.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((premium) => {
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PremiumInterestStateService } from "./premium-interest-state.service.abstraction";
|
||||
|
||||
@Injectable()
|
||||
export class NoopPremiumInterestStateService implements PremiumInterestStateService {
|
||||
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
|
||||
return null;
|
||||
} // no-op
|
||||
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {} // no-op
|
||||
async clearPremiumInterest(userId: UserId): Promise<void> {} // no-op
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* A service that manages state which conveys whether or not a user has expressed interest
|
||||
* in setting up a premium subscription. This applies for users who began the registration
|
||||
* process on https://bitwarden.com/go/start-premium/, which is a marketing page designed
|
||||
* to streamline users who intend to setup a premium subscription after registration.
|
||||
* - Implemented in Web only. No-op for other clients.
|
||||
*/
|
||||
export abstract class PremiumInterestStateService {
|
||||
abstract getPremiumInterest(userId: UserId): Promise<boolean | null>;
|
||||
abstract setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void>;
|
||||
abstract clearPremiumInterest(userId: UserId): Promise<void>;
|
||||
}
|
||||
@@ -151,6 +151,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
@@ -158,6 +159,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
|
||||
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
|
||||
import {
|
||||
DefaultKeyGenerationService,
|
||||
@@ -376,6 +378,8 @@ import { DefaultSetInitialPasswordService } from "../auth/password-management/se
|
||||
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
|
||||
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
import { DocumentLangSetter } from "../platform/i18n";
|
||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||
@@ -1459,6 +1463,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useClass: DefaultSubscriptionPricingService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useClass: DefaultOrganizationManagementPreferencesService,
|
||||
@@ -1716,6 +1725,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultNewDeviceVerificationComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PremiumInterestStateService,
|
||||
useClass: NoopPremiumInterestStateService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
@@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
// Value = hours
|
||||
@@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
protected premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||
@@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => {
|
||||
this.type = val;
|
||||
this.typeChanged();
|
||||
});
|
||||
this.formGroup.controls.type.valueChanges
|
||||
.pipe(
|
||||
tap((val) => {
|
||||
this.type = val;
|
||||
}),
|
||||
switchMap(() => this.typeChanged()),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.formGroup.controls.selectedDeletionDatePreset.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
|
||||
typeChanged() {
|
||||
async typeChanged() {
|
||||
if (this.type === SendType.File && !this.alertShown) {
|
||||
if (!this.canAccessPremium) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("premiumRequired");
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else if (!this.emailVerified) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("emailVerificationRequired");
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
bitInput
|
||||
appAutofocus
|
||||
(input)="onEmailInput($event)"
|
||||
(keyup.enter)="continuePressed()"
|
||||
(keyup.enter)="ssoRequired ? handleSsoClick() : continuePressed()"
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
377
libs/auth/src/common/login-strategies/README.md
Normal file
377
libs/auth/src/common/login-strategies/README.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Overview of Authentication at Bitwarden
|
||||
|
||||
> **Table of Contents**
|
||||
>
|
||||
> - [Authentication Methods](#authentication-methods)
|
||||
> - [The Login Credentials Object](#the-login-credentials-object)
|
||||
> - [The `LoginStrategyService` and our Login Strategies](#the-loginstrategyservice-and-our-login-strategies)
|
||||
> - [The `logIn()` and `startLogIn()` Methods](#the-login-and-startlogin-methods)
|
||||
> - [Handling the `AuthResult`](#handling-the-authresult)
|
||||
> - [Diagram of Authentication Flows](#diagram-of-authentication-flows)
|
||||
|
||||
<br>
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
Bitwarden provides 5 methods for logging in to Bitwarden, as defined in our [`AuthenticationType`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/enums/authentication-type.ts) enum. They are:
|
||||
|
||||
1. [Login with Master Password](https://bitwarden.com/help/bitwarden-security-white-paper/#authentication-and-decryption)
|
||||
2. [Login with Auth Request](https://bitwarden.com/help/log-in-with-device/) (aka Login with Device) — authenticate with a one-time access code
|
||||
3. [Login with Single Sign-On](https://bitwarden.com/help/about-sso/) — authenticate with an SSO Identity Provider (IdP) through SAML or OpenID Connect (OIDC)
|
||||
4. [Login with Passkey](https://bitwarden.com/help/login-with-passkeys/) (WebAuthn)
|
||||
5. [Login with User API Key](https://bitwarden.com/help/personal-api-key/) — authenticate with an API key and secret
|
||||
|
||||
<br>
|
||||
|
||||
**Login Initiation**
|
||||
|
||||
_Angular Clients - Initiating Components_
|
||||
|
||||
A user begins the login process by entering their email on the `/login` screen (`LoginComponent`). From there, the user must click one of the following buttons to initiate a login method by navigating to that method's associated "initiating component":
|
||||
|
||||
- `"Continue"` → user stays on the `LoginComponent` and enters a Master Password
|
||||
- `"Log in with device"` → navigates user to `LoginViaAuthRequestComponent`
|
||||
- `"Use single sign-on"` → navigates user to `SsoComponent`
|
||||
- `"Log in with passkey"` → navigates user to `LoginViaWebAuthnComponent`
|
||||
- Note: Login with Passkey is currently not available on the Desktop client.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> - Our Angular clients do not support the Login with User API Key method.
|
||||
>
|
||||
> - The Login with Master Password method is also used by the
|
||||
> `RegistrationFinishComponent` and `CompleteTrialInitiationComponent` (the user automatically
|
||||
> gets logged in with their Master Password after registration), as well as the `RecoverTwoFactorComponent`
|
||||
> (the user logs in with their Master Password along with their 2FA recovery code).
|
||||
|
||||
<br>
|
||||
|
||||
_CLI Client - `LoginCommand`_
|
||||
|
||||
The CLI client supports the following login methods via the `LoginCommand`:
|
||||
|
||||
- Login with Master Password
|
||||
- Login with Single Sign-On
|
||||
- Login with User API Key (which can _only_ be initiated from the CLI client)
|
||||
|
||||
<br>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> While each authentication method has its own unique logic, this document discusses the
|
||||
> logic that is _generally_ common to all authentication methods. It provides a high-level
|
||||
> overview of authentication and as such will involve some abstraction and generalization.
|
||||
|
||||
<br>
|
||||
|
||||
## The Login Credentials Object
|
||||
|
||||
When the user presses the "submit" action on an initiating component (or via `LoginCommand` for CLI), we build a **login credentials object**, which contains the core credentials needed to initiate the specific login method.
|
||||
|
||||
For example, when the user clicks "Log in with master password" on the `LoginComponent`, we build a `PasswordLoginCredentials` object, which is defined as:
|
||||
|
||||
```typescript
|
||||
export class PasswordLoginCredentials {
|
||||
readonly type = AuthenticationType.Password;
|
||||
|
||||
constructor(
|
||||
public email: string,
|
||||
public masterPassword: string,
|
||||
public twoFactor?: TokenTwoFactorRequest,
|
||||
public masterPasswordPoliciesFromOrgInvite?: MasterPasswordPolicyOptions,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the `type` is automatically set to `AuthenticationType.Password`, and the `PasswordLoginCredentials` object simply requires an `email` and `masterPassword` to initiate the login method.
|
||||
|
||||
Each authentication method builds its own type of credentials object. These are defined in [`login-credentials.ts`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/models/domain/login-credentials.ts).
|
||||
|
||||
- `PasswordLoginCredentials`
|
||||
- `AuthRequestLoginCredentials`
|
||||
- `SsoLoginCredentials`
|
||||
- `WebAuthnLoginCredentials`
|
||||
- `UserApiLoginCredentials`
|
||||
|
||||
After building the credentials object, we then call the `logIn()` method on the `LoginStrategyService`, passing in the credentials object as an argument: `LoginStrategyService.logIn(credentials)`
|
||||
|
||||
<br>
|
||||
|
||||
## The `LoginStrategyService` and our Login Strategies
|
||||
|
||||
The [`LoginStrategyService`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/services/login-strategies/login-strategy.service.ts) acts as an orchestrator that determines which of our specific **login strategies** should be initialized and used for the login process.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Our authentication methods are handled by different [login strategies](https://github.com/bitwarden/clients/tree/main/libs/auth/src/common/login-strategies), making use of the [Strategy Design Pattern](https://refactoring.guru/design-patterns/strategy). Those strategies are:
|
||||
>
|
||||
> - `PasswordLoginStrategy`
|
||||
> - `AuthRequestLoginStrategy`
|
||||
> - `SsoLoginStrategy`
|
||||
> - `WebAuthnLoginStrategy`
|
||||
> - `UserApiLoginStrategy`
|
||||
>
|
||||
> Each of those strategies extend the base [`LoginStrategy`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/login-strategies/login.strategy.ts), which houses common login logic.
|
||||
|
||||
More specifically, within its `logIn()` method, the `LoginStrategyService` uses the `type` property on the credentials object to determine which specific login strategy to initialize.
|
||||
|
||||
For example, the `PasswordLoginCredentials` object has `type` of `AuthenticationType.Password`. This tells the `LoginStrategyService` to initialize and use the `PasswordLoginStrategy` for the login process.
|
||||
|
||||
Once the `LoginStrategyService` initializes the appropriate strategy, it then calls the `logIn()` method defined on _that_ particular strategy, passing on the credentials object as an argument. For example: `PasswordLoginStrategy.logIn(credentials)`
|
||||
|
||||
<br>
|
||||
|
||||
To summarize everything so far:
|
||||
|
||||
```bash
|
||||
Initiating Component (Submit Action) # ex: LoginComponent.submit()
|
||||
|
|
||||
Build credentials object # ex: PasswordLoginCredentials
|
||||
|
|
||||
Call LoginStrategyService.logIn(credentials)
|
||||
|
|
||||
Initialize specific strategy # ex: PasswordLoginStrategy
|
||||
|
|
||||
Call strategy.logIn(credentials) # ex: PasswordLoginStrategy.logIn(credentials)
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## The `logIn()` and `startLogIn()` Methods
|
||||
|
||||
Each login strategy has its own unique implementation of the `logIn()` method, but each `logIn()` method performs the following general logic with the help of the credentials object:
|
||||
|
||||
1. Build a `LoginStrategyData` object with a `TokenRequest` property
|
||||
2. Cache the `LoginStrategyData` object
|
||||
3. Call the `startLogIn()` method on the base `LoginStrategy`
|
||||
|
||||
Here are those steps in more detail:
|
||||
|
||||
1. **Build a `LoginStrategyData` object with a `TokenRequest` property**
|
||||
|
||||
Each strategy uses the credentials object to help build a type of `LoginStrategyData` object, which contains the data needed throughout the lifetime of the particular strategy, and must, at minimum, contain a `tokenRequest` property (more on this below).
|
||||
|
||||
```typescript
|
||||
export abstract class LoginStrategyData {
|
||||
tokenRequest:
|
||||
| PasswordTokenRequest
|
||||
| SsoTokenRequest
|
||||
| WebAuthnLoginTokenRequest
|
||||
| UserApiTokenRequest
|
||||
| undefined;
|
||||
|
||||
abstract userEnteredEmail?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Each strategy has its own class that implements the `LoginStrategyData` interface:
|
||||
- `PasswordLoginStrategyData`
|
||||
- `AuthRequestLoginStrategyData`
|
||||
- `SsoLoginStrategyData`
|
||||
- `WebAuthnLoginStrategyData`
|
||||
- `UserApiLoginStrategyData`
|
||||
|
||||
So in our ongoing example that uses the "Login with Master Password" method, the call to `PasswordLoginStrategy.logIn(PasswordLoginCredentials)` would build a `PasswordLoginStrategyData` object that contains the data needed throughout the lifetime of the `PasswordLoginStrategy`.
|
||||
|
||||
That `PasswordLoginStrategyData` object is defined as:
|
||||
|
||||
```typescript
|
||||
export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
|
||||
userEnteredEmail: string;
|
||||
localMasterKeyHash: string;
|
||||
masterKey: MasterKey;
|
||||
forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
}
|
||||
```
|
||||
|
||||
Each of the `LoginStrategyData` types have varying properties, but one property common to all is the `tokenRequest` property.
|
||||
|
||||
The `tokenRequest` property holds some type of [`TokenRequest`](https://github.com/bitwarden/clients/tree/main/libs/common/src/auth/models/request/identity-token) object based on the strategy:
|
||||
- `PasswordTokenRequest` — used by both `PasswordLoginStrategy` and `AuthRequestLoginStrategy`
|
||||
- `SsoTokenRequest`
|
||||
- `WebAuthnLoginTokenRequest`
|
||||
- `UserApiTokenRequest`
|
||||
|
||||
This `TokenRequest` object is _also_ built within the `logIn()` method and gets added to the `LoginStrategyData` object as the `tokenRequest` property.
|
||||
|
||||
<br />
|
||||
|
||||
2. **Cache the `LoginStrategyData` object**
|
||||
|
||||
Because a login attempt could "fail" due to a need for Two Factor Authentication (2FA) or New Device Verification (NDV), we need to preserve the `LoginStrategyData` so that we can re-use it later when the user provides their 2FA or NDV token. This way, the user does not need to completely re-enter all of their credentials.
|
||||
|
||||
The way we cache this `LoginStrategyData` is simply by saving it to a property called `cache` on the strategy. There will be more details on how this cache is used later on.
|
||||
|
||||
<br />
|
||||
|
||||
3. **Call the `startLogIn()` method on the base `LoginStrategy`**
|
||||
|
||||
Next, we call the `startLogIn()` method, which exists on the base `LoginStrategy` and is therefore common to all login strategies. The `startLogIn()` method does the following:
|
||||
1. **Makes a `POST` request to the `/connect/token` endpoint on our Identity Server**
|
||||
- `REQUEST`
|
||||
|
||||
The exact payload for this request is determined by the `TokenRequest` object. More specifically, the base `TokenRequest` class contains a `toIdentityToken()` method which gets overridden/extended by the sub-classes (`PasswordTokenRequest.toIdentityToken()`, etc.). This `toIdentityToken()` method produces the exact payload that gets sent to our `/connect/token` endpoint.
|
||||
|
||||
The payload includes OAuth2 parameters, such as `scope`, `client_id`, and `grant_type`, as well as any other credentials that the server needs to complete validation for the specific authentication method.
|
||||
|
||||
- `RESPONSE`
|
||||
|
||||
The Identity Server validates the request and then generates some type of `IdentityResponse`, which can be one of three types:
|
||||
- [`IdentityTokenResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-token.response.ts)
|
||||
- Meaning: the user has been authenticated
|
||||
- Response Contains:
|
||||
- Authentication information, such as:
|
||||
- An access token (which is a JWT with claims about the user)
|
||||
- A refresh token
|
||||
- Decryption information, such as:
|
||||
- The user's master-key-encrypted user key (if the user has a master password), along with their KDF settings
|
||||
- The user's user-key-encrypted private key
|
||||
- A `userDecryptionOptions` object that contains information about which decryption options the user has available to them
|
||||
- A flag that indicates if the user is required to set or change their master password
|
||||
- Any master password policies the user is required to adhere to
|
||||
|
||||
- [`IdentityTwoFactorResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-two-factor.response.ts)
|
||||
- Meaning: the user needs to complete Two Factor Authentication
|
||||
- Response Contains:
|
||||
- A list of which 2FA providers the user has configured
|
||||
- Any master password policies the user is required to adhere to
|
||||
|
||||
- [`IdentityDeviceVerificationResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-device-verification.response.ts)
|
||||
- Meaning: the user needs to verify their new device via [new device verification](https://bitwarden.com/help/new-device-verification/)
|
||||
- Response Contains: a simple boolean property that states whether or not the device has been verified
|
||||
|
||||
2. **Calls one of the `process[IdentityType]Response()` methods**
|
||||
|
||||
Each of these methods builds and returns an [`AuthResult`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/domain/auth-result.ts) object, which gets used later to determine how to direct the user after an authentication attempt.
|
||||
|
||||
The specific method that gets called depends on the type of the `IdentityResponse`:
|
||||
- If `IdentityTokenResponse` → call `processTokenResponse()`
|
||||
- Instantiates a new `AuthResult` object
|
||||
- Calls `saveAccountInformation()` to initialize the account with information from the `IdentityTokenResponse`
|
||||
- Decodes the access token (a JWT) to get information about the user (userId, email, etc.)
|
||||
- Sets several things to state:
|
||||
- The account (via `AccountService`)
|
||||
- The user's environment
|
||||
- `userDecryptionOptions`
|
||||
- `masterPasswordUnlockData` (_if_ `userDecryptionOptions` allows for master password unlock):
|
||||
- Salt
|
||||
- KDF config
|
||||
- Master-key-encrypted user key
|
||||
- Access token and refresh token
|
||||
- KDF config
|
||||
- Premium status
|
||||
- If the `IdentityTokenResponse` contains a `twoFactorToken` (because the user previously selected "remember me" for their 2FA method), set that token to state
|
||||
- Sets cryptographic properties to state: master key, user key, private key
|
||||
- Sets a `forceSetPasswordReason` to state (if necessary)
|
||||
- Returns the `AuthResult`
|
||||
|
||||
- If `IdentityTwoFactorResponse` → call `processTwoFactorResponse()`
|
||||
- Instantiates a new `AuthResult` object
|
||||
- Sets `AuthResult.twoFactorProviders` to the list of 2FA providers from the `IdentityTwoFactorResponse`
|
||||
- Sets that same list of of 2FA providers to global state (memory)
|
||||
- Returns the `AuthResult`
|
||||
|
||||
- If `IdentityDeviceVerificationResponse` → call `processDeviceVerificationResponse()`
|
||||
- Instantiates a new `AuthResult` object
|
||||
- Sets `AuthResult.requiresDeviceVerification` to `true`
|
||||
- Returns the `AuthResult`
|
||||
|
||||
<br>
|
||||
|
||||
## Handling the `AuthResult`
|
||||
|
||||
The `AuthResult` object that gets returned from the `process[IdentityType]Response()` method ultimately gets returned up through the chain of callers until it makes its way back to the initiating component (ex: the `LoginComponent` for Login with Master Password).
|
||||
|
||||
The initiating component will then use the information on that `AuthResult` to determine how to direct the user after an authentication attempt.
|
||||
|
||||
Below is a high-level overview of how the `AuthResult` is handled, but note again that there are abstractions in this diagram — it doesn't depict every edge case, and is just meant to give a general picture.
|
||||
|
||||
```bash
|
||||
Initiating Component (Submit Action) < - - -
|
||||
| \
|
||||
LoginStrategyService.logIn() - \
|
||||
| \ # AuthResult bubbles back up
|
||||
strategy.logIn() - \ # through chain of callers
|
||||
| \ # to the initiating component
|
||||
startLogIn() - \
|
||||
| \
|
||||
process[IdentityType]Response() - \
|
||||
| \
|
||||
returns AuthResult - - - - - - - -
|
||||
|
||||
|
|
||||
- - - - - - - - - - # Initiating component then
|
||||
| # uses the AuthResult in
|
||||
handleAuthResult(authResult) # handleAuthResult()
|
||||
|
|
||||
IF AuthResult.requiresTwoFactor
|
||||
| # route user to /2fa to complete 2FA
|
||||
|
|
||||
IF AuthResult.requiresDeviceVerification
|
||||
| # route user to /device-verification to complete NDV
|
||||
|
|
||||
# Otherwise, route user to /vault
|
||||
```
|
||||
|
||||
<br />
|
||||
|
||||
Now for a more detailed breakdown of how the `AuthResult` is handled...
|
||||
|
||||
There are two broad types of scenarios that the user will fall into:
|
||||
|
||||
1. Re-submit scenarios
|
||||
2. Successful Authentication scenarios
|
||||
|
||||
### Re-submit Scenarios
|
||||
|
||||
There are two cases where a user is required to provide additional information before they can be authenticated: Two Factor Authentication (2FA) and New Device Verification (NDV). In these scenarios, we actually need the user to "re-submit" their original request, along with their added 2FA or NDV token. But remember earlier that we cached the `LoginStrategyData`. This makes it so the user does not need to re-enter their original credentials. Instead, the user simply provides their 2FA or NDV token, we add it to their original (cached) `LoginStrategyData`, and then we re-submit the request.
|
||||
|
||||
Here is how these scenarios work:
|
||||
|
||||
**User must complete Two Factor Authentication**
|
||||
|
||||
1. Remember that when the server response is `IdentityTwoFactorResponse`, we set 2FA provider data into state, and also set `requiresTwoFactor` to `true` on the `AuthResult`.
|
||||
2. When `AuthResult.requiresTwoFactor` is `true`, the specific login strategy exports its `LoginStrategyData` to the `LoginStrategyService`, where it gets stored in memory. This means the `LoginStrategyService` has a cache of the original request the user sent.
|
||||
3. We route the user to `/2fa` (`TwoFactorAuthComponent`).
|
||||
4. The user enters their 2FA token.
|
||||
5. On submission, the `LoginStrategyService` calls `logInTwoFactor()` on the particular login strategy. This method then:
|
||||
- Takes the cached `LoginStrategyData` (the user's original request), and appends the 2FA token onto the `TokenRequest`
|
||||
- Calls `startLogIn()` again, this time using the updated `LoginStrategyData` that includes the 2FA token.
|
||||
|
||||
**User must complete New Device Verification**
|
||||
|
||||
Note that we currently only require new device verification on Master Password logins (`PasswordLoginStrategy`) for users who do not have a 2FA method setup.
|
||||
|
||||
1. Remember that when the server response is `IdentityDeviceVerificationResponse`, we set `requiresDeviceVerification` to `true` on the `AuthResult`.
|
||||
2. When `AuthResult.requiresDeviceVerification` is `true`, the specific login strategy exports its `LoginStrategyData` to the `LoginStrategyService`, where it gets stored in memory. This means the `LoginStrategyService` has a cache of the original request the user sent.
|
||||
3. We route the user to `/device-verification`.
|
||||
4. The user enters their NDV token.
|
||||
5. On submission, the `LoginStrategyService` calls `logInNewDeviceVerification()` on the particular login strategy. This method then:
|
||||
- Takes the cached `LoginStrategyData` (the user's original request), and appends the NDV token onto the `TokenRequest`.
|
||||
- Calls `startLogIn()` again, this time using the updated `LoginStrategyData` that includes the NDV token.
|
||||
|
||||
### Successful Authentication Scenarios
|
||||
|
||||
**User must change their password**
|
||||
|
||||
A user can be successfully authenticated but still required to set/change their master password. In this case, the user gets routed to the relevant set/change password component (`SetInitialPassword` or `ChangePassword`).
|
||||
|
||||
**User does not need to complete 2FA, NDV, or set/change their master password**
|
||||
|
||||
In this case, the user proceeds to their `/vault`.
|
||||
|
||||
**Trusted Device Encryption scenario**
|
||||
|
||||
If the user is on an untrusted device, they get routed to `/login-initiated` to select a decryption option. If the user is on a trusted device, they get routed to `/vault` because decryption can be done automatically.
|
||||
|
||||
<br>
|
||||
|
||||
## Diagram of Authentication Flows
|
||||
|
||||
Here is a high-level overview of what all of this looks like in the end.
|
||||
|
||||
<br>
|
||||
|
||||

|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 215 KiB |
@@ -14,10 +14,4 @@ export abstract class AuditService {
|
||||
* @returns A promise that resolves to an array of BreachAccountResponse objects.
|
||||
*/
|
||||
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
/**
|
||||
* Checks if a domain is known for phishing.
|
||||
* @param domain The domain to check.
|
||||
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
|
||||
*/
|
||||
abstract getKnownPhishingDomains: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { PolicyService } from "../policy/policy.service.abstraction";
|
||||
|
||||
export function canAccessVaultTab(org: Organization): boolean {
|
||||
return org.canViewAllCollections;
|
||||
@@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessEmergencyAccess(
|
||||
userId: UserId,
|
||||
configService: ConfigService,
|
||||
policyService: PolicyService,
|
||||
) {
|
||||
return combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Please use the general `getById` custom rxjs operator instead.
|
||||
*/
|
||||
|
||||
@@ -554,6 +554,77 @@ describe("PolicyService", () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
describe("SingleOrg policy exemptions", () => {
|
||||
it("returns true for SingleOrg policy when AutoConfirm is enabled, even for users who can manage policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org6", PolicyType.AutoConfirm, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is not enabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([policyData("policy1", "org6", PolicyType.SingleOrg, true)]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org6", PolicyType.AutoConfirm, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for SingleOrg policy for regular users when AutoConfirm is not enabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([policyData("policy1", "org1", PolicyType.SingleOrg, true)]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SingleOrg policy when AutoConfirm is enabled in a different organization", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org1", PolicyType.AutoConfirm, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => {
|
||||
|
||||
@@ -40,18 +40,16 @@ export class DefaultPolicyService implements PolicyService {
|
||||
}
|
||||
|
||||
policiesByType$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.policies$(userId).pipe(
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No userId provided");
|
||||
}
|
||||
|
||||
const allPolicies$ = this.policies$(userId);
|
||||
const organizations$ = this.organizationService.organizations$(userId);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
return combineLatest([allPolicies$, organizations$]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +75,7 @@ export class DefaultPolicyService implements PolicyService {
|
||||
policy.enabled &&
|
||||
organization.status >= OrganizationUserStatusType.Accepted &&
|
||||
organization.usePolicies &&
|
||||
!this.isExemptFromPolicy(policy.type, organization)
|
||||
!this.isExemptFromPolicy(policy.type, organization, policies)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -265,7 +263,11 @@ export class DefaultPolicyService implements PolicyService {
|
||||
* Determines whether an orgUser is exempt from a specific policy because of their role
|
||||
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
|
||||
*/
|
||||
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
|
||||
private isExemptFromPolicy(
|
||||
policyType: PolicyType,
|
||||
organization: Organization,
|
||||
allPolicies: Policy[],
|
||||
) {
|
||||
switch (policyType) {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
@@ -286,6 +288,14 @@ export class DefaultPolicyService implements PolicyService {
|
||||
case PolicyType.OrganizationDataOwnership:
|
||||
// organization data ownership policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.SingleOrg:
|
||||
// Check if AutoConfirm policy is enabled for this organization
|
||||
return allPolicies.find(
|
||||
(p) =>
|
||||
p.organizationId === organization.id && p.type === PolicyType.AutoConfirm && p.enabled,
|
||||
)
|
||||
? false
|
||||
: organization.canManagePolicies;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import { MasterPasswordPolicyResponse } from "./master-password-policy.response"
|
||||
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
|
||||
|
||||
export class IdentityTokenResponse extends BaseResponse {
|
||||
// Authentication Information
|
||||
accessToken: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
tokenType: string;
|
||||
|
||||
// Decryption Information
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTier,
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export abstract class SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getPersonalSubscriptionPricingTiers$(): Observable<PersonalSubscriptionPricingTier[]>;
|
||||
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getBusinessSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
|
||||
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getDeveloperSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
411
libs/common/src/billing/services/subscription-pricing.service.ts
Normal file
411
libs/common/src/billing/services/subscription-pricing.service.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import {
|
||||
combineLatest,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
import { catchError } from "rxjs/operators";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PlanType } 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
BusinessSubscriptionPricingTierIds,
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Fallback premium pricing used when the feature flag is disabled.
|
||||
* These values represent the legacy pricing model and will not reflect
|
||||
* server-side price changes. They are retained for backward compatibility
|
||||
* during the feature flag rollout period.
|
||||
*/
|
||||
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
||||
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.premium$, this.families$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load personal subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load business subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load developer subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(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 premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
.pipe(
|
||||
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
|
||||
switchMap((fetchPremiumFromPricingService) =>
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
||||
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
||||
}),
|
||||
),
|
||||
map((premiumPrices) => ({
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: this.i18nService.t("premium"),
|
||||
description: this.i18nService.t("planDescPremium"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: premiumPrices.seat,
|
||||
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
this.featureTranslations.emergencyAccess(),
|
||||
this.featureTranslations.breachMonitoring(),
|
||||
this.featureTranslations.andMoreFeatures(),
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
|
||||
|
||||
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,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
name: this.i18nService.t("planNameFree"),
|
||||
description: this.i18nService.t("planDescFreeV2", "1"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
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)!;
|
||||
|
||||
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,
|
||||
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,
|
||||
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(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
private featureTranslations = {
|
||||
builtInAuthenticator: () => ({
|
||||
key: "builtInAuthenticator",
|
||||
value: this.i18nService.t("builtInAuthenticator"),
|
||||
}),
|
||||
emergencyAccess: () => ({
|
||||
key: "emergencyAccess",
|
||||
value: this.i18nService.t("emergencyAccess"),
|
||||
}),
|
||||
breachMonitoring: () => ({
|
||||
key: "breachMonitoring",
|
||||
value: this.i18nService.t("breachMonitoring"),
|
||||
}),
|
||||
andMoreFeatures: () => ({
|
||||
key: "andMoreFeatures",
|
||||
value: this.i18nService.t("andMoreFeatures"),
|
||||
}),
|
||||
premiumAccounts: () => ({
|
||||
key: "premiumAccounts",
|
||||
value: this.i18nService.t("premiumAccounts"),
|
||||
}),
|
||||
secureFileStorage: () => ({
|
||||
key: "secureFileStorage",
|
||||
value: this.i18nService.t("secureFileStorage"),
|
||||
}),
|
||||
familiesUnlimitedSharing: () => ({
|
||||
key: "familiesUnlimitedSharing",
|
||||
value: this.i18nService.t("familiesUnlimitedSharing"),
|
||||
}),
|
||||
familiesUnlimitedCollections: () => ({
|
||||
key: "familiesUnlimitedCollections",
|
||||
value: this.i18nService.t("familiesUnlimitedCollections"),
|
||||
}),
|
||||
familiesSharedStorage: () => ({
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
alwaysFree: () => ({
|
||||
key: "alwaysFree",
|
||||
value: this.i18nService.t("alwaysFree"),
|
||||
}),
|
||||
twoSecretsIncluded: () => ({
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
secureItemSharing: () => ({
|
||||
key: "secureItemSharing",
|
||||
value: this.i18nService.t("secureItemSharing"),
|
||||
}),
|
||||
eventLogMonitoring: () => ({
|
||||
key: "eventLogMonitoring",
|
||||
value: this.i18nService.t("eventLogMonitoring"),
|
||||
}),
|
||||
directoryIntegration: () => ({
|
||||
key: "directoryIntegration",
|
||||
value: this.i18nService.t("directoryIntegration"),
|
||||
}),
|
||||
scimSupport: () => ({
|
||||
key: "scimSupport",
|
||||
value: this.i18nService.t("scimSupport"),
|
||||
}),
|
||||
unlimitedSecretsAndProjects: () => ({
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
enterpriseSecurityPolicies: () => ({
|
||||
key: "enterpriseSecurityPolicies",
|
||||
value: this.i18nService.t("enterpriseSecurityPolicies"),
|
||||
}),
|
||||
passwordLessSso: () => ({
|
||||
key: "passwordLessSso",
|
||||
value: this.i18nService.t("passwordLessSso"),
|
||||
}),
|
||||
accountRecovery: () => ({
|
||||
key: "accountRecovery",
|
||||
value: this.i18nService.t("accountRecovery"),
|
||||
}),
|
||||
selfHostOption: () => ({
|
||||
key: "selfHostOption",
|
||||
value: this.i18nService.t("selfHostOption"),
|
||||
}),
|
||||
complimentaryFamiliesPlan: () => ({
|
||||
key: "complimentaryFamiliesPlan",
|
||||
value: this.i18nService.t("complimentaryFamiliesPlan"),
|
||||
}),
|
||||
unlimitedUsers: () => ({
|
||||
key: "unlimitedUsers",
|
||||
value: this.i18nService.t("unlimitedUsers"),
|
||||
}),
|
||||
strengthenCybersecurity: () => ({
|
||||
key: "strengthenCybersecurity",
|
||||
value: this.i18nService.t("strengthenCybersecurity"),
|
||||
}),
|
||||
boostProductivity: () => ({
|
||||
key: "boostProductivity",
|
||||
value: this.i18nService.t("boostProductivity"),
|
||||
}),
|
||||
seamlessIntegration: () => ({
|
||||
key: "seamlessIntegration",
|
||||
value: this.i18nService.t("seamlessIntegration"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
85
libs/common/src/billing/types/subscription-pricing-tier.ts
Normal file
85
libs/common/src/billing/types/subscription-pricing-tier.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export const PersonalSubscriptionPricingTierIds = {
|
||||
Premium: "premium",
|
||||
Families: "families",
|
||||
} as const;
|
||||
|
||||
export const BusinessSubscriptionPricingTierIds = {
|
||||
Free: "free",
|
||||
Teams: "teams",
|
||||
Enterprise: "enterprise",
|
||||
Custom: "custom",
|
||||
} as const;
|
||||
|
||||
export const SubscriptionCadenceIds = {
|
||||
Annually: "annually",
|
||||
Monthly: "monthly",
|
||||
} as const;
|
||||
|
||||
export type PersonalSubscriptionPricingTierId =
|
||||
(typeof PersonalSubscriptionPricingTierIds)[keyof typeof PersonalSubscriptionPricingTierIds];
|
||||
export type BusinessSubscriptionPricingTierId =
|
||||
(typeof BusinessSubscriptionPricingTierIds)[keyof typeof BusinessSubscriptionPricingTierIds];
|
||||
export type SubscriptionCadence =
|
||||
(typeof SubscriptionCadenceIds)[keyof typeof SubscriptionCadenceIds];
|
||||
|
||||
type HasFeatures = {
|
||||
features: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
type: "free";
|
||||
};
|
||||
|
||||
type CustomPasswordManager = HasFeatures & {
|
||||
type: "custom";
|
||||
};
|
||||
|
||||
type ScalablePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
type: "free";
|
||||
};
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierId;
|
||||
name: string;
|
||||
description: string;
|
||||
availableCadences: Omit<SubscriptionCadence, "monthly">[]; // personal plans are only ever annual
|
||||
passwordManager: StandalonePasswordManager | PackagedPasswordManager;
|
||||
};
|
||||
|
||||
export type BusinessSubscriptionPricingTier = {
|
||||
id: BusinessSubscriptionPricingTierId;
|
||||
name: string;
|
||||
description: string;
|
||||
availableCadences: SubscriptionCadence[];
|
||||
passwordManager: FreePasswordManager | ScalablePasswordManager | CustomPasswordManager;
|
||||
secretsManager?: FreeSecretsManager | ScalableSecretsManager;
|
||||
};
|
||||
@@ -30,6 +30,7 @@ export enum FeatureFlag {
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -118,6 +119,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -22,13 +22,15 @@ export class MasterPasswordUnlockResponse extends BaseResponse {
|
||||
|
||||
this.kdf = new KdfConfigResponse(this.getResponseProperty("Kdf"));
|
||||
|
||||
const masterKeyEncryptedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
|
||||
if (masterKeyEncryptedUserKey == null || typeof masterKeyEncryptedUserKey !== "string") {
|
||||
// Note: MasterKeyEncryptedUserKey and masterKeyWrappedUserKey are the same thing, and
|
||||
// used inconsistently in the codebase
|
||||
const masterKeyWrappedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
|
||||
if (masterKeyWrappedUserKey == null || typeof masterKeyWrappedUserKey !== "string") {
|
||||
throw new Error(
|
||||
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
|
||||
);
|
||||
}
|
||||
this.masterKeyWrappedUserKey = masterKeyEncryptedUserKey as MasterKeyWrappedUserKey;
|
||||
this.masterKeyWrappedUserKey = masterKeyWrappedUserKey as MasterKeyWrappedUserKey;
|
||||
}
|
||||
|
||||
toMasterPasswordUnlockData() {
|
||||
|
||||
@@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async getKnownPhishingDomains(): Promise<string[]> {
|
||||
const response = await this.apiService.send("GET", "/phishing-domains", null, true, true);
|
||||
return response as string[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AttachmentResponse } from "../response/attachment.response";
|
||||
|
||||
export class AttachmentData {
|
||||
id: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
key: string;
|
||||
size: string;
|
||||
sizeName: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
key?: string;
|
||||
size?: string;
|
||||
sizeName?: string;
|
||||
|
||||
constructor(response?: AttachmentResponse) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CardApi } from "../api/card.api";
|
||||
|
||||
export class CardData {
|
||||
cardholderName: string;
|
||||
brand: string;
|
||||
number: string;
|
||||
expMonth: string;
|
||||
expYear: string;
|
||||
code: string;
|
||||
cardholderName?: string;
|
||||
brand?: string;
|
||||
number?: string;
|
||||
expMonth?: string;
|
||||
expYear?: string;
|
||||
code?: string;
|
||||
|
||||
constructor(data?: CardApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
@@ -17,18 +15,18 @@ import { SecureNoteData } from "./secure-note.data";
|
||||
import { SshKeyData } from "./ssh-key.data";
|
||||
|
||||
export class CipherData {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
folderId: string;
|
||||
edit: boolean;
|
||||
viewPassword: boolean;
|
||||
permissions: CipherPermissionsApi;
|
||||
organizationUseTotp: boolean;
|
||||
favorite: boolean;
|
||||
id: string = "";
|
||||
organizationId?: string;
|
||||
folderId?: string;
|
||||
edit: boolean = false;
|
||||
viewPassword: boolean = true;
|
||||
permissions?: CipherPermissionsApi;
|
||||
organizationUseTotp: boolean = false;
|
||||
favorite: boolean = false;
|
||||
revisionDate: string;
|
||||
type: CipherType;
|
||||
name: string;
|
||||
notes: string;
|
||||
type: CipherType = CipherType.Login;
|
||||
name: string = "";
|
||||
notes?: string;
|
||||
login?: LoginData;
|
||||
secureNote?: SecureNoteData;
|
||||
card?: CardData;
|
||||
@@ -39,13 +37,14 @@ export class CipherData {
|
||||
passwordHistory?: PasswordHistoryData[];
|
||||
collectionIds?: string[];
|
||||
creationDate: string;
|
||||
deletedDate: string | undefined;
|
||||
archivedDate: string | undefined;
|
||||
reprompt: CipherRepromptType;
|
||||
key: string;
|
||||
deletedDate?: string;
|
||||
archivedDate?: string;
|
||||
reprompt: CipherRepromptType = CipherRepromptType.None;
|
||||
key?: string;
|
||||
|
||||
constructor(response?: CipherResponse, collectionIds?: string[]) {
|
||||
if (response == null) {
|
||||
this.creationDate = this.revisionDate = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,7 +100,9 @@ export class CipherData {
|
||||
|
||||
static fromJSON(obj: Jsonify<CipherData>) {
|
||||
const result = Object.assign(new CipherData(), obj);
|
||||
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
if (obj.permissions != null) {
|
||||
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Fido2CredentialApi } from "../api/fido2-credential.api";
|
||||
|
||||
export class Fido2CredentialData {
|
||||
credentialId: string;
|
||||
keyType: "public-key";
|
||||
keyAlgorithm: "ECDSA";
|
||||
keyCurve: "P-256";
|
||||
keyValue: string;
|
||||
rpId: string;
|
||||
userHandle: string;
|
||||
userName: string;
|
||||
counter: string;
|
||||
rpName: string;
|
||||
userDisplayName: string;
|
||||
discoverable: string;
|
||||
creationDate: string;
|
||||
credentialId!: string;
|
||||
keyType!: string;
|
||||
keyAlgorithm!: string;
|
||||
keyCurve!: string;
|
||||
keyValue!: string;
|
||||
rpId!: string;
|
||||
userHandle?: string;
|
||||
userName?: string;
|
||||
counter!: string;
|
||||
rpName?: string;
|
||||
userDisplayName?: string;
|
||||
discoverable!: string;
|
||||
creationDate!: string;
|
||||
|
||||
constructor(data?: Fido2CredentialApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FieldType, LinkedIdType } from "../../enums";
|
||||
import { FieldApi } from "../api/field.api";
|
||||
|
||||
export class FieldData {
|
||||
type: FieldType;
|
||||
name: string;
|
||||
value: string;
|
||||
linkedId: LinkedIdType | null;
|
||||
type: FieldType = FieldType.Text;
|
||||
name?: string;
|
||||
value?: string;
|
||||
linkedId?: LinkedIdType;
|
||||
|
||||
constructor(response?: FieldApi) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { IdentityApi } from "../api/identity.api";
|
||||
|
||||
export class IdentityData {
|
||||
title: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
address3: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
ssn: string;
|
||||
username: string;
|
||||
passportNumber: string;
|
||||
licenseNumber: string;
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
middleName?: string;
|
||||
lastName?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
company?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
ssn?: string;
|
||||
username?: string;
|
||||
passportNumber?: string;
|
||||
licenseNumber?: string;
|
||||
|
||||
constructor(data?: IdentityApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
|
||||
export class LoginUriData {
|
||||
uri: string;
|
||||
uriChecksum: string;
|
||||
match: UriMatchStrategySetting = null;
|
||||
uri?: string;
|
||||
uriChecksum?: string;
|
||||
match?: UriMatchStrategySetting;
|
||||
|
||||
constructor(data?: LoginUriApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { LoginApi } from "../api/login.api";
|
||||
|
||||
import { Fido2CredentialData } from "./fido2-credential.data";
|
||||
import { LoginUriData } from "./login-uri.data";
|
||||
|
||||
export class LoginData {
|
||||
uris: LoginUriData[];
|
||||
username: string;
|
||||
password: string;
|
||||
passwordRevisionDate: string;
|
||||
totp: string;
|
||||
autofillOnPageLoad: boolean;
|
||||
uris?: LoginUriData[];
|
||||
username?: string;
|
||||
password?: string;
|
||||
passwordRevisionDate?: string;
|
||||
totp?: string;
|
||||
autofillOnPageLoad?: boolean;
|
||||
fido2Credentials?: Fido2CredentialData[];
|
||||
|
||||
constructor(data?: LoginApi) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { PasswordHistoryResponse } from "../response/password-history.response";
|
||||
|
||||
export class PasswordHistoryData {
|
||||
password: string;
|
||||
lastUsedDate: string;
|
||||
password!: string;
|
||||
lastUsedDate!: string;
|
||||
|
||||
constructor(response?: PasswordHistoryResponse) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SecureNoteType } from "../../enums";
|
||||
import { SecureNoteApi } from "../api/secure-note.api";
|
||||
|
||||
export class SecureNoteData {
|
||||
type: SecureNoteType;
|
||||
type: SecureNoteType = SecureNoteType.Generic;
|
||||
|
||||
constructor(data?: SecureNoteApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
|
||||
export class SshKeyData {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyFingerprint: string;
|
||||
privateKey!: string;
|
||||
publicKey!: string;
|
||||
keyFingerprint!: string;
|
||||
|
||||
constructor(data?: SshKeyApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -39,6 +39,12 @@ describe("Attachment", () => {
|
||||
key: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
expect(data.id).toBeUndefined();
|
||||
expect(data.url).toBeUndefined();
|
||||
expect(data.fileName).toBeUndefined();
|
||||
expect(data.key).toBeUndefined();
|
||||
expect(data.size).toBeUndefined();
|
||||
expect(data.sizeName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -29,6 +29,13 @@ describe("Card", () => {
|
||||
expYear: undefined,
|
||||
code: undefined,
|
||||
});
|
||||
|
||||
expect(data.cardholderName).toBeUndefined();
|
||||
expect(data.brand).toBeUndefined();
|
||||
expect(data.number).toBeUndefined();
|
||||
expect(data.expMonth).toBeUndefined();
|
||||
expect(data.expYear).toBeUndefined();
|
||||
expect(data.code).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -44,22 +44,22 @@ describe("Cipher DTO", () => {
|
||||
const data = new CipherData();
|
||||
const cipher = new Cipher(data);
|
||||
|
||||
expect(cipher.id).toBeUndefined();
|
||||
expect(cipher.id).toEqual("");
|
||||
expect(cipher.organizationId).toBeUndefined();
|
||||
expect(cipher.folderId).toBeUndefined();
|
||||
expect(cipher.name).toBeInstanceOf(EncString);
|
||||
expect(cipher.notes).toBeUndefined();
|
||||
expect(cipher.type).toBeUndefined();
|
||||
expect(cipher.favorite).toBeUndefined();
|
||||
expect(cipher.organizationUseTotp).toBeUndefined();
|
||||
expect(cipher.edit).toBeUndefined();
|
||||
expect(cipher.viewPassword).toBeUndefined();
|
||||
expect(cipher.type).toEqual(CipherType.Login);
|
||||
expect(cipher.favorite).toEqual(false);
|
||||
expect(cipher.organizationUseTotp).toEqual(false);
|
||||
expect(cipher.edit).toEqual(false);
|
||||
expect(cipher.viewPassword).toEqual(true);
|
||||
expect(cipher.revisionDate).toBeInstanceOf(Date);
|
||||
expect(cipher.collectionIds).toEqual([]);
|
||||
expect(cipher.localData).toBeUndefined();
|
||||
expect(cipher.creationDate).toBeInstanceOf(Date);
|
||||
expect(cipher.deletedDate).toBeUndefined();
|
||||
expect(cipher.reprompt).toBeUndefined();
|
||||
expect(cipher.reprompt).toEqual(CipherRepromptType.None);
|
||||
expect(cipher.attachments).toBeUndefined();
|
||||
expect(cipher.fields).toBeUndefined();
|
||||
expect(cipher.passwordHistory).toBeUndefined();
|
||||
@@ -836,6 +836,38 @@ describe("Cipher DTO", () => {
|
||||
expect(actual).toBeInstanceOf(Cipher);
|
||||
});
|
||||
|
||||
it("handles null permissions correctly without calling CipherPermissionsApi constructor", () => {
|
||||
const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any);
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const actual = Cipher.fromJSON({
|
||||
name: "myName",
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
permissions: null,
|
||||
} as Jsonify<Cipher>);
|
||||
|
||||
expect(actual.permissions).toBeUndefined();
|
||||
expect(actual).toBeInstanceOf(Cipher);
|
||||
// Verify that CipherPermissionsApi constructor was not called for null permissions
|
||||
expect(spy).not.toHaveBeenCalledWith(null);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls CipherPermissionsApi constructor when permissions are provided", () => {
|
||||
const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any);
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const permissionsObj = { delete: true, restore: false };
|
||||
const actual = Cipher.fromJSON({
|
||||
name: "myName",
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
permissions: permissionsObj,
|
||||
} as Jsonify<Cipher>);
|
||||
|
||||
expect(actual.permissions).toBeInstanceOf(CipherPermissionsApi);
|
||||
expect(actual.permissions.delete).toBe(true);
|
||||
expect(actual.permissions.restore).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
// Test description, CipherType, expected output
|
||||
["LoginView", CipherType.Login, { login: "myLogin_fromJSON" }],
|
||||
@@ -1056,6 +1088,7 @@ describe("Cipher DTO", () => {
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
data: undefined,
|
||||
favorite: false,
|
||||
reprompt: SdkCipherRepromptType.None,
|
||||
organizationUseTotp: true,
|
||||
|
||||
@@ -421,6 +421,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
switch (this.type) {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Field", () => {
|
||||
const field = new Field(data);
|
||||
|
||||
expect(field).toEqual({
|
||||
type: undefined,
|
||||
type: FieldType.Text,
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
linkedId: undefined,
|
||||
|
||||
@@ -53,6 +53,27 @@ describe("Identity", () => {
|
||||
title: undefined,
|
||||
username: undefined,
|
||||
});
|
||||
|
||||
expect(data).toEqual({
|
||||
title: undefined,
|
||||
firstName: undefined,
|
||||
middleName: undefined,
|
||||
lastName: undefined,
|
||||
address1: undefined,
|
||||
address2: undefined,
|
||||
address3: undefined,
|
||||
city: undefined,
|
||||
state: undefined,
|
||||
postalCode: undefined,
|
||||
country: undefined,
|
||||
company: undefined,
|
||||
email: undefined,
|
||||
phone: undefined,
|
||||
ssn: undefined,
|
||||
username: undefined,
|
||||
passportNumber: undefined,
|
||||
licenseNumber: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockEnc, mockFromJson } from "../../../../spec";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { UriMatchStrategy } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
import { LoginUriData } from "../data/login-uri.data";
|
||||
|
||||
import { LoginUri } from "./login-uri";
|
||||
@@ -31,6 +32,9 @@ describe("LoginUri", () => {
|
||||
uri: undefined,
|
||||
uriChecksum: undefined,
|
||||
});
|
||||
expect(data.uri).toBeUndefined();
|
||||
expect(data.uriChecksum).toBeUndefined();
|
||||
expect(data.match).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
@@ -61,6 +65,23 @@ describe("LoginUri", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handle null match", () => {
|
||||
const apiData = Object.assign(new LoginUriApi(), {
|
||||
uri: "testUri",
|
||||
uriChecksum: "testChecksum",
|
||||
match: null,
|
||||
});
|
||||
|
||||
const loginUriData = new LoginUriData(apiData);
|
||||
|
||||
// The data model stores it as-is (null or undefined)
|
||||
expect(loginUriData.match).toBeNull();
|
||||
|
||||
// But the domain model converts null to undefined
|
||||
const loginUri = new LoginUri(loginUriData);
|
||||
expect(loginUri.match).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("validateChecksum", () => {
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
|
||||
@@ -118,7 +139,7 @@ describe("LoginUri", () => {
|
||||
});
|
||||
|
||||
describe("SDK Login Uri Mapping", () => {
|
||||
it("should map to SDK login uri", () => {
|
||||
it("maps to SDK login uri", () => {
|
||||
const loginUri = new LoginUri(data);
|
||||
const sdkLoginUri = loginUri.toSdkLoginUri();
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ describe("Login DTO", () => {
|
||||
password: undefined,
|
||||
totp: undefined,
|
||||
});
|
||||
|
||||
expect(data.username).toBeUndefined();
|
||||
expect(data.password).toBeUndefined();
|
||||
expect(data.passwordRevisionDate).toBeUndefined();
|
||||
expect(data.totp).toBeUndefined();
|
||||
expect(data.autofillOnPageLoad).toBeUndefined();
|
||||
expect(data.uris).toBeUndefined();
|
||||
expect(data.fido2Credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert from full LoginData", () => {
|
||||
|
||||
@@ -111,10 +111,7 @@ export class Login extends Domain {
|
||||
});
|
||||
|
||||
if (this.uris != null && this.uris.length > 0) {
|
||||
l.uris = [];
|
||||
this.uris.forEach((u) => {
|
||||
l.uris.push(u.toLoginUriData());
|
||||
});
|
||||
l.uris = this.uris.map((u) => u.toLoginUriData());
|
||||
}
|
||||
|
||||
if (this.fido2Credentials != null && this.fido2Credentials.length > 0) {
|
||||
|
||||
@@ -20,6 +20,9 @@ describe("Password", () => {
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password.password).toBeInstanceOf(EncString);
|
||||
expect(password.lastUsedDate).toBeInstanceOf(Date);
|
||||
|
||||
expect(data.password).toBeUndefined();
|
||||
expect(data.lastUsedDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
@@ -83,4 +86,47 @@ describe("Password", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkPasswordHistory", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates Password from SDK object", () => {
|
||||
const sdkPasswordHistory = {
|
||||
password: "2.encPassword|encryptedData" as EncryptedString,
|
||||
lastUsedDate: "2022-01-31T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const password = Password.fromSdkPasswordHistory(sdkPasswordHistory);
|
||||
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password?.password).toBeInstanceOf(EncString);
|
||||
expect(password?.password.encryptedString).toBe("2.encPassword|encryptedData");
|
||||
expect(password?.lastUsedDate).toEqual(new Date("2022-01-31T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("returns undefined for null input", () => {
|
||||
const result = Password.fromSdkPasswordHistory(null as any);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for undefined input", () => {
|
||||
const result = Password.fromSdkPasswordHistory(undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles empty SDK object", () => {
|
||||
const sdkPasswordHistory = {
|
||||
password: "" as EncryptedString,
|
||||
lastUsedDate: "",
|
||||
};
|
||||
|
||||
const password = Password.fromSdkPasswordHistory(sdkPasswordHistory);
|
||||
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password?.password).toBeInstanceOf(EncString);
|
||||
expect(password?.lastUsedDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,22 +16,27 @@ describe("SecureNote", () => {
|
||||
const data = new SecureNoteData();
|
||||
const secureNote = new SecureNote(data);
|
||||
|
||||
expect(secureNote).toEqual({
|
||||
type: undefined,
|
||||
});
|
||||
expect(data).toBeDefined();
|
||||
expect(secureNote).toEqual({ type: SecureNoteType.Generic });
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Convert from undefined", () => {
|
||||
const data = new SecureNoteData(undefined);
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
const secureNote = new SecureNote(data);
|
||||
|
||||
expect(secureNote).toEqual({
|
||||
type: 0,
|
||||
});
|
||||
expect(secureNote).toEqual({ type: 0 });
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("toSecureNoteData", () => {
|
||||
const secureNote = new SecureNote(data);
|
||||
expect(secureNote.toSecureNoteData()).toEqual(data);
|
||||
expect(secureNote.toSecureNoteData().type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Decrypt", async () => {
|
||||
@@ -49,6 +54,14 @@ describe("SecureNote", () => {
|
||||
it("returns undefined if object is null", () => {
|
||||
expect(SecureNote.fromJSON(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SecureNote instance from JSON object", () => {
|
||||
const jsonObj = { type: SecureNoteType.Generic };
|
||||
const result = SecureNote.fromJSON(jsonObj);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkSecureNote", () => {
|
||||
@@ -63,4 +76,71 @@ describe("SecureNote", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkSecureNote", () => {
|
||||
it("returns undefined when null is provided", () => {
|
||||
const result = SecureNote.fromSdkSecureNote(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when undefined is provided", () => {
|
||||
const result = SecureNote.fromSdkSecureNote(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SecureNote with Generic type from SDK object", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("preserves the type value from SDK object", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result.type).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a new SecureNote instance", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).not.toBe(sdkSecureNote);
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
});
|
||||
|
||||
it("handles SDK object with undefined type", () => {
|
||||
const sdkSecureNote = {
|
||||
type: undefined as SecureNoteType,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns symmetric with toSdkSecureNote", () => {
|
||||
const original = new SecureNote();
|
||||
original.type = SecureNoteType.Generic;
|
||||
|
||||
const sdkFormat = original.toSdkSecureNote();
|
||||
const reconstructed = SecureNote.fromSdkSecureNote(sdkFormat);
|
||||
|
||||
expect(reconstructed.type).toBe(original.type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { EncString as SdkEncString, SshKey as SdkSshKey } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { mockEnc } from "../../../../spec";
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
@@ -37,6 +38,9 @@ describe("Sshkey", () => {
|
||||
expect(sshKey.privateKey).toBeInstanceOf(EncString);
|
||||
expect(sshKey.publicKey).toBeInstanceOf(EncString);
|
||||
expect(sshKey.keyFingerprint).toBeInstanceOf(EncString);
|
||||
expect(data.privateKey).toBeUndefined();
|
||||
expect(data.publicKey).toBeUndefined();
|
||||
expect(data.keyFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toSshKeyData", () => {
|
||||
@@ -64,6 +68,21 @@ describe("Sshkey", () => {
|
||||
it("returns undefined if object is null", () => {
|
||||
expect(SshKey.fromJSON(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SshKey instance from JSON object", () => {
|
||||
const jsonObj = {
|
||||
privateKey: "2.privateKey|encryptedData",
|
||||
publicKey: "2.publicKey|encryptedData",
|
||||
keyFingerprint: "2.keyFingerprint|encryptedData",
|
||||
};
|
||||
|
||||
const result = SshKey.fromJSON(jsonObj);
|
||||
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.keyFingerprint).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkSshKey", () => {
|
||||
@@ -78,4 +97,58 @@ describe("Sshkey", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkSshKey", () => {
|
||||
it("returns undefined when null is provided", () => {
|
||||
const result = SshKey.fromSdkSshKey(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when undefined is provided", () => {
|
||||
const result = SshKey.fromSdkSshKey(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SshKey from SDK object", () => {
|
||||
const sdkSshKey: SdkSshKey = {
|
||||
privateKey: "2.privateKey|encryptedData" as SdkEncString,
|
||||
publicKey: "2.publicKey|encryptedData" as SdkEncString,
|
||||
fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString,
|
||||
};
|
||||
|
||||
const result = SshKey.fromSdkSshKey(sdkSshKey);
|
||||
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.keyFingerprint).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates a new SshKey instance", () => {
|
||||
const sdkSshKey: SdkSshKey = {
|
||||
privateKey: "2.privateKey|encryptedData" as SdkEncString,
|
||||
publicKey: "2.publicKey|encryptedData" as SdkEncString,
|
||||
fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString,
|
||||
};
|
||||
|
||||
const result = SshKey.fromSdkSshKey(sdkSshKey);
|
||||
|
||||
expect(result).not.toBe(sdkSshKey);
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
});
|
||||
|
||||
it("is symmetric with toSdkSshKey", () => {
|
||||
const original = new SshKey(data);
|
||||
const sdkFormat = original.toSdkSshKey();
|
||||
const reconstructed = SshKey.fromSdkSshKey(sdkFormat);
|
||||
|
||||
expect(reconstructed.privateKey.encryptedString).toBe(original.privateKey.encryptedString);
|
||||
expect(reconstructed.publicKey.encryptedString).toBe(original.publicKey.encryptedString);
|
||||
expect(reconstructed.keyFingerprint.encryptedString).toBe(
|
||||
original.keyFingerprint.encryptedString,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,20 +77,20 @@ export default {
|
||||
[hideBackgroundIllustration]="hideBackgroundIllustration"
|
||||
>
|
||||
<ng-container [ngSwitch]="contentLength">
|
||||
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-bold">Thin Content</div></div>
|
||||
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-medium">Thin Content</div></div>
|
||||
<div *ngSwitchCase="'long'">
|
||||
<div class="tw-font-bold">Long Content</div>
|
||||
<div class="tw-font-medium">Long Content</div>
|
||||
<div>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
|
||||
<div>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
|
||||
</div>
|
||||
<div *ngSwitchDefault>
|
||||
<div class="tw-font-bold">Normal Content</div>
|
||||
<div class="tw-font-medium">Normal Content</div>
|
||||
<div>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. </div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="showSecondary" slot="secondary" class="tw-text-center">
|
||||
<div class="tw-font-bold tw-mb-2">
|
||||
<div class="tw-font-medium tw-mb-2">
|
||||
Secondary Projected Content (optional)
|
||||
</div>
|
||||
<button type="button" bitButton>Perform Action</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ const SizeClasses: Record<SizeTypes, string[]> = {
|
||||
[attr.fill]="textColor()"
|
||||
[style.fontWeight]="svgFontWeight"
|
||||
[style.fontSize.px]="svgFontSize"
|
||||
font-family='Roboto,"Helvetica Neue",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
|
||||
font-family='Inter,"Helvetica Neue",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
|
||||
>
|
||||
{{ displayChars() }}
|
||||
</text>
|
||||
|
||||
@@ -69,7 +69,7 @@ const buttonStyles: Record<ButtonType, string[]> = {
|
||||
export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-font-medium",
|
||||
"tw-rounded-full",
|
||||
"tw-transition",
|
||||
"tw-border-2",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
<div class="tw-flex tw-flex-col tw-gap-0.5">
|
||||
@if (title) {
|
||||
<header id="{{ titleId }}" class="tw-text-base tw-font-semibold">
|
||||
<header id="{{ titleId }}" class="tw-text-base tw-font-medium">
|
||||
{{ title }}
|
||||
</header>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for
|
||||
|
||||
import { ColorPasswordComponent } from "./color-password.component";
|
||||
|
||||
const examplePassword = "Wq$Jk😀7j DX#rS5Sdi!z ";
|
||||
const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z ";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Color Password",
|
||||
|
||||
@@ -126,7 +126,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-font-medium",
|
||||
"tw-leading-[0px]",
|
||||
"tw-border-none",
|
||||
"tw-transition",
|
||||
|
||||
@@ -25,7 +25,7 @@ const commonStyles = [
|
||||
"tw-leading-none",
|
||||
"tw-px-0",
|
||||
"tw-py-0.5",
|
||||
"tw-font-semibold",
|
||||
"tw-font-medium",
|
||||
"tw-bg-transparent",
|
||||
"tw-border-0",
|
||||
"tw-border-none",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-bold tw-h-full"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[ngClass]="{ 'tw-justify-center': !open }"
|
||||
>
|
||||
<i
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="tw-size-24 tw-content-center">
|
||||
<bit-icon [icon]="icon()" aria-hidden="true"></bit-icon>
|
||||
</div>
|
||||
<h3 class="tw-font-semibold tw-text-center tw-mt-4">
|
||||
<h3 class="tw-font-medium tw-text-center tw-mt-4">
|
||||
<ng-content select="[slot=title]"></ng-content>
|
||||
</h3>
|
||||
<p class="tw-text-center">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
class="tw-relative tw-z-20 tw-w-72 tw-break-words tw-bg-primary-100 tw-pb-4 tw-pt-2 tw-text-main"
|
||||
>
|
||||
<div class="tw-me-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-ps-4">
|
||||
<h2 bitTypography="h5" class="tw-font-semibold tw-mt-1">
|
||||
<h2 bitTypography="h5" class="tw-font-medium tw-mt-1">
|
||||
{{ title() }}
|
||||
</h2>
|
||||
<button
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ProgressComponent {
|
||||
"tw-items-center",
|
||||
"tw-whitespace-nowrap",
|
||||
"tw-text-xs",
|
||||
"tw-font-semibold",
|
||||
"tw-font-medium",
|
||||
"tw-text-contrast",
|
||||
"tw-transition-all",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@if (label()) {
|
||||
<fieldset>
|
||||
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-semibold tw-text-main">
|
||||
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-medium tw-text-main">
|
||||
<ng-content select="bit-label"></ng-content>
|
||||
@if (required) {
|
||||
<span class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
>
|
||||
@if (step.completed) {
|
||||
<span
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-medium tw-leading-9 tw-text-contrast"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
} @else {
|
||||
<span
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-bold tw-leading-9"
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-medium tw-leading-9"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-600 tw-text-contrast': selectedIndex === $index,
|
||||
'tw-bg-secondary-300 tw-text-main':
|
||||
@@ -83,13 +83,13 @@
|
||||
>
|
||||
@if (step.completed) {
|
||||
<span
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-medium tw-leading-9 tw-text-contrast"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
} @else {
|
||||
<span
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-bold tw-leading-9"
|
||||
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-medium tw-leading-9"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-600 tw-text-contrast': selectedIndex === $index,
|
||||
'tw-bg-secondary-300 tw-text-main':
|
||||
|
||||
@@ -61,14 +61,14 @@ This is easy to verify. Bitwarden prefixes all Tailwind classes with `tw-`. If y
|
||||
without this prefix, it probably shouldn't be there.
|
||||
|
||||
<div class="tw-bg-danger-600/10 tw-p-4">
|
||||
<span class="tw-font-bold tw-text-danger">Bad (Bootstrap)</span>
|
||||
<span class="tw-font-medium tw-text-danger">Bad (Bootstrap)</span>
|
||||
```html
|
||||
<div class="mb-2"></div>
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="tw-bg-success-600/10 tw-p-4">
|
||||
<span class="tw-font-bold tw-text-success">Good (Tailwind)</span>
|
||||
<span class="tw-font-medium tw-text-success">Good (Tailwind)</span>
|
||||
```html
|
||||
<div class="tw-mb-2"></div>
|
||||
```
|
||||
@@ -77,7 +77,7 @@ without this prefix, it probably shouldn't be there.
|
||||
**Exception:** Icon font classes, prefixed with `bwi`, are allowed.
|
||||
|
||||
<div class="tw-bg-success-600/10 tw-p-4">
|
||||
<span class="tw-font-bold tw-text-success">Good (Icons)</span>
|
||||
<span class="tw-font-medium tw-text-success">Good (Icons)</span>
|
||||
```html
|
||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
|
||||
```
|
||||
@@ -91,7 +91,7 @@ reactive forms to make use of these components. Review the
|
||||
[form component docs](?path=/docs/component-library-form--docs).
|
||||
|
||||
<div class="tw-bg-danger-600/10 tw-p-4">
|
||||
<span class="tw-text-danger tw-font-bold">Bad</span>
|
||||
<span class="tw-text-danger tw-font-medium">Bad</span>
|
||||
```html
|
||||
<form #form (ngSubmit)="submit()">
|
||||
...
|
||||
@@ -100,7 +100,7 @@ reactive forms to make use of these components. Review the
|
||||
</div>
|
||||
|
||||
<div class="tw-bg-success-600/10 tw-p-4">
|
||||
<span class="tw-text-success tw-font-bold">Good</span>
|
||||
<span class="tw-text-success tw-font-medium">Good</span>
|
||||
```html
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
...
|
||||
|
||||
@@ -106,7 +106,7 @@ export class SortableComponent implements OnInit {
|
||||
get classList() {
|
||||
return [
|
||||
"tw-min-w-max",
|
||||
"tw-font-bold",
|
||||
"tw-font-medium",
|
||||
|
||||
// Below is copied from BitIconButtonComponent
|
||||
"tw-border",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<table [ngClass]="tableClass">
|
||||
<thead
|
||||
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
|
||||
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-medium tw-text-muted"
|
||||
>
|
||||
<tr>
|
||||
<ng-content select="[header]"></ng-content>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<table [ngClass]="tableClass">
|
||||
<thead
|
||||
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
|
||||
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-medium tw-text-muted"
|
||||
>
|
||||
<ng-content select="[header]"></ng-content>
|
||||
</thead>
|
||||
|
||||
@@ -60,7 +60,7 @@ export class TabListItemDirective implements FocusableOption {
|
||||
"tw-relative",
|
||||
"tw-py-2",
|
||||
"tw-px-4",
|
||||
"tw-font-semibold",
|
||||
"tw-font-medium",
|
||||
"tw-transition",
|
||||
"tw-rounded-t-lg",
|
||||
"tw-border-0",
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* Font faces
|
||||
*/
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-family: Inter;
|
||||
src:
|
||||
url("~@bitwarden/components/src/webfonts/roboto.woff2") format("woff2 supports variations"),
|
||||
url("~@bitwarden/components/src/webfonts/roboto.woff2") format("woff2-variations");
|
||||
url("~@bitwarden/components/src/webfonts/inter.woff2") format("woff2 supports variations"),
|
||||
url("~@bitwarden/components/src/webfonts/inter.woff2") format("woff2-variations");
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
:root {
|
||||
--font-sans:
|
||||
Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
Inter, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: Menlo, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div>
|
||||
<span class="tw-sr-only">{{ variant() | i18n }}</span>
|
||||
@if (title(); as title) {
|
||||
<p data-testid="toast-title" class="tw-font-semibold tw-mb-0">{{ title }}</p>
|
||||
<p data-testid="toast-title" class="tw-font-medium tw-mb-0">{{ title }}</p>
|
||||
}
|
||||
@for (m of messageArray; track m) {
|
||||
<p bitTypography="body2" data-testid="toast-message" class="tw-mb-2 last:tw-mb-0">
|
||||
|
||||
@@ -56,7 +56,7 @@ export class ToggleComponent<TValue> implements AfterContentChecked, AfterViewIn
|
||||
"tw-items-center",
|
||||
"tw-justify-center",
|
||||
"tw-gap-1.5",
|
||||
"!tw-font-semibold",
|
||||
"!tw-font-medium",
|
||||
"tw-leading-5",
|
||||
"tw-transition",
|
||||
"tw-text-center",
|
||||
|
||||
@@ -14,22 +14,22 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply tw-text-3xl tw-font-semibold tw-text-main tw-mb-2;
|
||||
@apply tw-text-3xl tw-text-main tw-mb-2;
|
||||
}
|
||||
h2 {
|
||||
@apply tw-text-2xl tw-font-semibold tw-text-main tw-mb-2;
|
||||
@apply tw-text-2xl tw-text-main tw-mb-2;
|
||||
}
|
||||
h3 {
|
||||
@apply tw-text-xl tw-font-semibold tw-text-main tw-mb-2;
|
||||
@apply tw-text-xl tw-text-main tw-mb-2;
|
||||
}
|
||||
h4 {
|
||||
@apply tw-text-lg tw-font-semibold tw-text-main tw-mb-2;
|
||||
@apply tw-text-lg tw-text-main tw-mb-2;
|
||||
}
|
||||
h5 {
|
||||
@apply tw-text-base tw-font-bold tw-text-main tw-mb-1.5;
|
||||
@apply tw-text-base tw-text-main tw-mb-1.5;
|
||||
}
|
||||
h6 {
|
||||
@apply tw-text-sm tw-font-bold tw-text-main tw-mb-1.5;
|
||||
@apply tw-text-sm tw-text-main tw-mb-1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
dt {
|
||||
@apply tw-font-bold;
|
||||
@apply tw-font-medium;
|
||||
}
|
||||
|
||||
hr {
|
||||
@@ -78,4 +78,8 @@
|
||||
svg {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply tw-font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { booleanAttribute, Directive, HostBinding, input } from "@angular/core";
|
||||
type TypographyType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body1" | "body2" | "helper";
|
||||
|
||||
const styles: Record<TypographyType, string[]> = {
|
||||
h1: ["!tw-text-3xl", "tw-font-semibold", "tw-text-main"],
|
||||
h2: ["!tw-text-2xl", "tw-font-semibold", "tw-text-main"],
|
||||
h3: ["!tw-text-xl", "tw-font-semibold", "tw-text-main"],
|
||||
h4: ["!tw-text-lg", "tw-font-semibold", "tw-text-main"],
|
||||
h5: ["!tw-text-base", "tw-font-bold", "tw-text-main"],
|
||||
h6: ["!tw-text-sm", "tw-font-bold", "tw-text-main"],
|
||||
h1: ["!tw-text-3xl", "tw-text-main"],
|
||||
h2: ["!tw-text-2xl", "tw-text-main"],
|
||||
h3: ["!tw-text-xl", "tw-text-main"],
|
||||
h4: ["!tw-text-lg", "tw-text-main"],
|
||||
h5: ["!tw-text-base", "tw-text-main"],
|
||||
h6: ["!tw-text-sm", "tw-text-main"],
|
||||
body1: ["!tw-text-base"],
|
||||
body2: ["!tw-text-sm"],
|
||||
helper: ["!tw-text-xs"],
|
||||
|
||||
@@ -1,28 +1,226 @@
|
||||
import { Meta, StoryObj } from "@storybook/angular";
|
||||
import { Meta, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { TableModule, TableDataSource } from "../table";
|
||||
|
||||
import { TypographyDirective } from "./typography.directive";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Typography",
|
||||
component: TypographyDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [TableModule],
|
||||
}),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
export const Default: StoryObj<TypographyDirective & { text: string }> = {
|
||||
render: (args) => ({
|
||||
type TypographyData = {
|
||||
id: string;
|
||||
typography: string;
|
||||
classes?: string;
|
||||
weight: string;
|
||||
size: number;
|
||||
lineHeight: string;
|
||||
};
|
||||
|
||||
const typographyProps: TypographyData[] = [
|
||||
{
|
||||
id: "h1",
|
||||
typography: "h1",
|
||||
weight: "Regular",
|
||||
size: 30,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h2",
|
||||
typography: "h2",
|
||||
weight: "Regular",
|
||||
size: 24,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h3",
|
||||
typography: "h3",
|
||||
weight: "Regular",
|
||||
size: 20,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h4",
|
||||
typography: "h4",
|
||||
weight: "Regular",
|
||||
size: 18,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h5",
|
||||
typography: "h5",
|
||||
weight: "Regular",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h6",
|
||||
typography: "h6",
|
||||
weight: "Regular",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body",
|
||||
typography: "body1",
|
||||
weight: "Regular",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-med",
|
||||
typography: "body1",
|
||||
classes: "tw-font-medium",
|
||||
weight: "Medium",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-semi",
|
||||
typography: "body1",
|
||||
classes: "tw-font-semibold",
|
||||
weight: "Semibold",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-underline",
|
||||
typography: "body1",
|
||||
classes: "tw-underline",
|
||||
weight: "Regular",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-sm",
|
||||
typography: "body2",
|
||||
weight: "Regular",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-sm-med",
|
||||
typography: "body2",
|
||||
classes: "tw-font-medium",
|
||||
weight: "Medium",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-sm-semi",
|
||||
typography: "body2",
|
||||
classes: "tw-font-semibold",
|
||||
weight: "Semibold",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "body-sm-underline",
|
||||
typography: "body2",
|
||||
classes: "tw-underline",
|
||||
weight: "Regular",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "helper",
|
||||
typography: "helper",
|
||||
weight: "Regular",
|
||||
size: 12,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "helper-med",
|
||||
typography: "helper",
|
||||
classes: "tw-font-medium",
|
||||
weight: "Medium",
|
||||
size: 12,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "helper-semi",
|
||||
typography: "helper",
|
||||
classes: "tw-font-semibold",
|
||||
weight: "Semibold",
|
||||
size: 12,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "helper-underline",
|
||||
typography: "helper",
|
||||
classes: "tw-underline",
|
||||
weight: "Regular",
|
||||
size: 12,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
typography: "body1",
|
||||
classes: "tw-font-mono tw-text-code",
|
||||
weight: "Regular",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "code-sm",
|
||||
typography: "body2",
|
||||
classes: "tw-font-mono tw-text-code",
|
||||
weight: "Regular",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
|
||||
{
|
||||
id: "code-helper",
|
||||
typography: "helper",
|
||||
classes: "tw-font-mono tw-text-code",
|
||||
weight: "Regular",
|
||||
size: 12,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
];
|
||||
|
||||
const typographyData = new TableDataSource<TypographyData>();
|
||||
typographyData.data = typographyProps;
|
||||
|
||||
export const Default = {
|
||||
render: (args: { text: string; dataSource: typeof typographyProps }) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div bitTypography="h1">h1 - {{ text }}</div>
|
||||
<div bitTypography="h2">h2 - {{ text }}</div>
|
||||
<div bitTypography="h3">h3 - {{ text }}</div>
|
||||
<div bitTypography="h4">h4 - {{ text }}</div>
|
||||
<div bitTypography="h5">h5 - {{ text }}</div>
|
||||
<div bitTypography="h6">h6 - {{ text }}</div>
|
||||
<div bitTypography="body1" class="tw-text-main">body1 - {{ text }}</div>
|
||||
<div bitTypography="body2" class="tw-text-main">body2 - {{ text }}</div>
|
||||
<div bitTypography="helper" class="tw-text-main">helper - {{ text }}</div>
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>Rendered Text</th>
|
||||
<th bitCell>bitTypography Variant</th>
|
||||
<th bitCell>Additional Classes</th>
|
||||
<th bitCell>Weight</th>
|
||||
<th bitCell>Size</th>
|
||||
<th bitCell>Line Height</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
@for (row of rows$ | async; track row.id) {
|
||||
<tr bitRow alignContent="middle">
|
||||
<td bitCell><div [bitTypography]="row.typography" [ngClass]="row.classes">{{text}}</div></td>
|
||||
<td bitCell bitTypography="body2">{{row.typography}}</td>
|
||||
<td bitCell bitTypography="body2">{{row.classes}}</td>
|
||||
<td bitCell bitTypography="body2">{{row.weight}}</td>
|
||||
<td bitCell bitTypography="body2">{{row.size}}</td>
|
||||
<td bitCell bitTypography="body2">{{row.lineHeight}}</td>
|
||||
</tr>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
text: `Sphinx of black quartz, judge my vow.`,
|
||||
dataSource: typographyData,
|
||||
},
|
||||
};
|
||||
|
||||
BIN
libs/components/src/webfonts/inter.woff2
Normal file
BIN
libs/components/src/webfonts/inter.woff2
Normal file
Binary file not shown.
Binary file not shown.
@@ -155,8 +155,14 @@ module.exports = {
|
||||
"90vw": "90vw",
|
||||
}),
|
||||
fontSize: {
|
||||
xs: [".8125rem", "1rem"],
|
||||
"3xl": ["1.75rem", "2rem"],
|
||||
"3xl": ["1.875rem", "150%"],
|
||||
"2xl": ["1.5rem", "150%"],
|
||||
xl: ["1.25rem", "150%"],
|
||||
lg: ["1.125rem", "150%"],
|
||||
md: ["1rem", "150%"],
|
||||
base: ["1rem", "150%"],
|
||||
sm: ["0.875rem", "150%"],
|
||||
xs: [".75rem", "150%"],
|
||||
},
|
||||
container: {
|
||||
"@5xl": "1100px",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" id="import_form_importForm">
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 class="tw-font-bold" bitTypography="h6">{{ "destination" | i18n }}</h2>
|
||||
<h2 class="tw-font-medium" bitTypography="h6">{{ "destination" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field [hidden]="isFromAC">
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 class="tw-font-bold" bitTypography="h6">{{ "data" | i18n }}</h2>
|
||||
<h2 class="tw-font-medium" bitTypography="h6">{{ "data" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field class="@2xl:tw-w-1/2">
|
||||
@@ -70,7 +70,7 @@
|
||||
<bit-select formControlName="format">
|
||||
<bit-option value="" label="-- {{ 'select' | i18n }} --" />
|
||||
<bit-option
|
||||
class="tw-font-bold tw-text-muted tw-text-xs"
|
||||
class="tw-font-medium tw-text-muted tw-text-xs"
|
||||
value="-"
|
||||
label="{{ 'commonImportFormats' | i18n }}"
|
||||
disabled
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mb-4">
|
||||
<p class="tw-mb-1 tw-text-sm tw-font-semibold">{{ "keyConnectorDomain" | i18n }}:</p>
|
||||
<p class="tw-mb-1 tw-text-sm tw-font-medium">{{ "keyConnectorDomain" | i18n }}:</p>
|
||||
<p class="tw-text-muted tw-break-all">{{ keyConnectorUrl }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk");
|
||||
|
||||
// Billing
|
||||
export const BILLING_DISK = new StateDefinition("billing", "disk");
|
||||
export const BILLING_MEMORY = new StateDefinition("billing", "memory");
|
||||
|
||||
// Auth
|
||||
|
||||
@@ -107,6 +108,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// DIRT
|
||||
|
||||
export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection", "disk");
|
||||
|
||||
// Platform
|
||||
|
||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||
|
||||
@@ -3,19 +3,11 @@
|
||||
{{ (hideIcon ? "createSend" : "new") | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<a
|
||||
bitMenuItem
|
||||
[routerLink]="buildRouterLink(sendType.Text)"
|
||||
[queryParams]="buildQueryParams(sendType.Text)"
|
||||
>
|
||||
<a bitMenuItem [routerLink]="buildRouterLink()" [queryParams]="buildQueryParams(sendType.Text)">
|
||||
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeText" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
bitMenuItem
|
||||
[routerLink]="buildRouterLink(sendType.File)"
|
||||
[queryParams]="buildQueryParams(sendType.File)"
|
||||
>
|
||||
<a bitMenuItem (click)="sendFileClick()">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeFile" | i18n }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { RouterLink } from "@angular/router";
|
||||
import { Router, RouterLink } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
@@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -32,6 +33,8 @@ export class NewSendDropdownComponent implements OnInit {
|
||||
constructor(
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private accountService: AccountService,
|
||||
private router: Router,
|
||||
private premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -46,18 +49,21 @@ export class NewSendDropdownComponent implements OnInit {
|
||||
));
|
||||
}
|
||||
|
||||
buildRouterLink(type: SendType) {
|
||||
if (this.hasNoPremium && type === SendType.File) {
|
||||
return "/premium";
|
||||
} else {
|
||||
return "/add-send";
|
||||
}
|
||||
buildRouterLink() {
|
||||
return "/add-send";
|
||||
}
|
||||
|
||||
buildQueryParams(type: SendType) {
|
||||
if (this.hasNoPremium && type === SendType.File) {
|
||||
return null;
|
||||
}
|
||||
return { type: type, isNew: true };
|
||||
}
|
||||
|
||||
async sendFileClick() {
|
||||
if (this.hasNoPremium) {
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else {
|
||||
await this.router.navigate([this.buildRouterLink()], {
|
||||
queryParams: this.buildQueryParams(SendType.File),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<bit-section *ngIf="sends?.length > 0" disableMargin>
|
||||
<bit-section-header>
|
||||
<h2 class="tw-font-bold" bitTypography="h6">
|
||||
<h2 class="tw-font-medium" bitTypography="h6">
|
||||
{{ headerText }}
|
||||
</h2>
|
||||
<span bitTypography="body1" slot="end">{{ sends.length }}</span>
|
||||
|
||||
@@ -201,12 +201,12 @@ describe("AutofillOptionsComponent", () => {
|
||||
|
||||
it("updates the default autofill on page load label", () => {
|
||||
fixture.detectChanges();
|
||||
expect(component["autofillOptions"][0].label).toEqual("defaultLabel no");
|
||||
expect(component["autofillOptions"][0].label).toEqual("defaultLabelWithValue no");
|
||||
|
||||
(autofillSettingsService.autofillOnPageLoadDefault$ as BehaviorSubject<boolean>).next(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["autofillOptions"][0].label).toEqual("defaultLabel yes");
|
||||
expect(component["autofillOptions"][0].label).toEqual("defaultLabelWithValue yes");
|
||||
});
|
||||
|
||||
it("hides the autofill on page load field when the setting is disabled", () => {
|
||||
|
||||
@@ -218,7 +218,10 @@ export class AutofillOptionsComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autofillOptions[0].label = this.i18nService.t("defaultLabel", defaultOption.label);
|
||||
this.autofillOptions[0].label = this.i18nService.t(
|
||||
"defaultLabelWithValue",
|
||||
defaultOption.label,
|
||||
);
|
||||
// Trigger change detection to update the label in the template
|
||||
this.autofillOptions = [...this.autofillOptions];
|
||||
});
|
||||
|
||||
@@ -77,19 +77,19 @@ describe("UriOptionComponent", () => {
|
||||
component.defaultMatchDetection = UriMatchStrategy.Domain;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["uriMatchOptions"][0].label).toBe("defaultLabel baseDomain");
|
||||
expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue baseDomain");
|
||||
});
|
||||
|
||||
it("should update the default uri match strategy label", () => {
|
||||
component.defaultMatchDetection = UriMatchStrategy.Exact;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["uriMatchOptions"][0].label).toBe("defaultLabel exact");
|
||||
expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue exact");
|
||||
|
||||
component.defaultMatchDetection = UriMatchStrategy.StartsWith;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["uriMatchOptions"][0].label).toBe("defaultLabel startsWith");
|
||||
expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue startsWith");
|
||||
});
|
||||
|
||||
it("should focus the uri input when focusInput is called", () => {
|
||||
|
||||
@@ -124,7 +124,7 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
}
|
||||
|
||||
this.uriMatchOptions[0].label = this.i18nService.t(
|
||||
"defaultLabel",
|
||||
"defaultLabelWithValue",
|
||||
this.uriMatchOptions.find((o) => o.value === value)?.label,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user