mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 08:43:33 +00:00
Merge branch 'main' into ac/pm-26363-one-time-setup-dialog-web
This commit is contained in:
21
libs/angular/src/auth/constants/auth-route.constant.ts
Normal file
21
libs/angular/src/auth/constants/auth-route.constant.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Constants for auth team owned full routes which are shared across clients.
|
||||
*/
|
||||
export const AuthRoute = Object.freeze({
|
||||
SignUp: "signup",
|
||||
FinishSignUp: "finish-signup",
|
||||
Login: "login",
|
||||
LoginWithDevice: "login-with-device",
|
||||
AdminApprovalRequested: "admin-approval-requested",
|
||||
PasswordHint: "hint",
|
||||
LoginInitiated: "login-initiated",
|
||||
SetInitialPassword: "set-initial-password",
|
||||
ChangePassword: "change-password",
|
||||
Sso: "sso",
|
||||
TwoFactor: "2fa",
|
||||
AuthenticationTimeout: "authentication-timeout",
|
||||
NewDeviceVerification: "device-verification",
|
||||
LoginWithPasskey: "login-with-passkey",
|
||||
} as const);
|
||||
|
||||
export type AuthRoute = (typeof AuthRoute)[keyof typeof AuthRoute];
|
||||
1
libs/angular/src/auth/constants/index.ts
Normal file
1
libs/angular/src/auth/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-route.constant";
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,31 @@ import { CalloutTypes } from "@bitwarden/components";
|
||||
/**
|
||||
* @deprecated use the CL's `CalloutComponent` instead
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-callout",
|
||||
templateUrl: "callout.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class DeprecatedCalloutComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() type: CalloutTypes = "info";
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() icon: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() title: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() enforcedPolicyMessage: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() useAlertRole = false;
|
||||
|
||||
calloutStyle: string;
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
|
||||
import { ModalRef } from "./modal.ref";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-modal",
|
||||
template: "<ng-template #modalContent></ng-template>",
|
||||
@@ -23,6 +25,8 @@ import { ModalRef } from "./modal.ref";
|
||||
export class DynamicModalComponent implements AfterViewInit, OnDestroy {
|
||||
componentRef: ComponentRef<any>;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild("modalContent", { read: ViewContainerRef, static: true })
|
||||
modalContentRef: ViewContainerRef;
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
|
||||
standalone: false,
|
||||
})
|
||||
export class ApiActionDirective implements OnChanges {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() appApiAction: Promise<any>;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -15,6 +15,8 @@ export class CopyTextDirective {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appCopyText") copyText: string;
|
||||
|
||||
@HostListener("copy") onCopy() {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core";
|
||||
standalone: false,
|
||||
})
|
||||
export class FallbackSrcDirective {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appFallbackSrc") appFallbackSrc: string;
|
||||
|
||||
/** Only try setting the fallback once. This prevents an infinite loop if the fallback itself is missing. */
|
||||
|
||||
@@ -13,6 +13,8 @@ const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag;
|
||||
const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag;
|
||||
const testStringFeatureValue = "test-value";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: `
|
||||
<div *appIfFeature="testBooleanFeature">
|
||||
|
||||
@@ -20,12 +20,16 @@ export class IfFeatureDirective implements OnInit {
|
||||
/**
|
||||
* The feature flag to check.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() appIfFeature: FeatureFlag;
|
||||
|
||||
/**
|
||||
* Optional value to compare against the value of the feature flag in the config service.
|
||||
* @default true
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() appIfFeatureValue: AllowedFeatureFlagTypes = true;
|
||||
|
||||
private hasView = false;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
|
||||
standalone: false,
|
||||
})
|
||||
export class InputVerbatimDirective implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() set appInputVerbatim(condition: boolean | string) {
|
||||
this.disableComplete = condition === "" || condition === true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
export class LaunchClickDirective {
|
||||
constructor(private platformUtilsService: PlatformUtilsService) {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appLaunchClick") uriToLaunch = "";
|
||||
|
||||
@HostListener("click") onClick() {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Directive, HostListener, Input } from "@angular/core";
|
||||
},
|
||||
})
|
||||
export class TextDragDirective {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({
|
||||
alias: "appTextDrag",
|
||||
required: true,
|
||||
|
||||
@@ -14,7 +14,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
standalone: false,
|
||||
})
|
||||
export class TrueFalseValueDirective implements ControlValueAccessor {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() trueValue: boolean | string = true;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() falseValue: boolean | string = false;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -153,6 +153,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";
|
||||
@@ -160,6 +161,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,
|
||||
@@ -970,7 +972,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: SignalRConnectionService,
|
||||
useClass: SignalRConnectionService,
|
||||
deps: [ApiServiceAbstraction, LogService],
|
||||
deps: [ApiServiceAbstraction, LogService, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebPushConnectionService,
|
||||
@@ -1237,7 +1239,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: AnonymousHubServiceAbstraction,
|
||||
useClass: AnonymousHubService,
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction],
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ValidationServiceAbstraction,
|
||||
@@ -1469,6 +1471,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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
block
|
||||
buttonType="primary"
|
||||
(click)="continuePressed()"
|
||||
[bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''"
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
{{ "continue" | i18n }}
|
||||
@@ -59,6 +61,8 @@
|
||||
block
|
||||
buttonType="secondary"
|
||||
(click)="handleLoginWithPasskeyClick()"
|
||||
[bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''"
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
@@ -67,7 +71,13 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- Button to Login with SSO -->
|
||||
<button type="button" bitButton block buttonType="secondary" (click)="handleSsoClick()">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
[buttonType]="ssoRequired ? 'primary' : 'secondary'"
|
||||
(click)="handleSsoClick()"
|
||||
>
|
||||
<i class="bwi bwi-provider tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "useSingleSignOn" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
TooltipDirective,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
|
||||
@@ -82,6 +83,7 @@ export enum LoginUiState {
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TooltipDirective,
|
||||
],
|
||||
})
|
||||
export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
@@ -16,6 +16,8 @@ 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";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
@@ -51,6 +53,25 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
|
||||
};
|
||||
}
|
||||
|
||||
function onlyHttpsValidator(): ValidatorFn {
|
||||
const i18nService = inject(I18nService);
|
||||
const platformUtilsService = inject(PlatformUtilsService);
|
||||
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const url = control.value as string;
|
||||
|
||||
if (url && !url.startsWith("https://") && !platformUtilsService.isDev()) {
|
||||
return {
|
||||
onlyHttpsAllowed: {
|
||||
message: i18nService.t("selfHostedEnvMustUseHttps"),
|
||||
},
|
||||
}; // invalid
|
||||
}
|
||||
|
||||
return null; // valid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for configuring self-hosted environment settings.
|
||||
*/
|
||||
@@ -89,12 +110,12 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
formGroup = this.formBuilder.group(
|
||||
{
|
||||
baseUrl: [""],
|
||||
webVaultUrl: [""],
|
||||
apiUrl: [""],
|
||||
identityUrl: [""],
|
||||
iconsUrl: [""],
|
||||
notificationsUrl: [""],
|
||||
baseUrl: ["", [onlyHttpsValidator()]],
|
||||
webVaultUrl: ["", [onlyHttpsValidator()]],
|
||||
apiUrl: ["", [onlyHttpsValidator()]],
|
||||
identityUrl: ["", [onlyHttpsValidator()]],
|
||||
iconsUrl: ["", [onlyHttpsValidator()]],
|
||||
notificationsUrl: ["", [onlyHttpsValidator()]],
|
||||
},
|
||||
{ validators: selfHostedEnvSettingsFormValidator() },
|
||||
);
|
||||
@@ -162,10 +183,11 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
this.showErrorSummary = false;
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
this.showErrorSummary = true;
|
||||
this.showErrorSummary = Boolean(this.formGroup.errors?.["atLeastOneUrlIsRequired"]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ import { CipherShareRequest } from "../vault/models/request/cipher-share.request
|
||||
import { CipherRequest } from "../vault/models/request/cipher.request";
|
||||
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
|
||||
import { AttachmentResponse } from "../vault/models/response/attachment.response";
|
||||
import { CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||
|
||||
/**
|
||||
@@ -215,7 +215,10 @@ export abstract class ApiService {
|
||||
id: string,
|
||||
request: CipherCollectionsRequest,
|
||||
): Promise<OptionalCipherResponse>;
|
||||
abstract putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any>;
|
||||
abstract putCipherCollectionsAdmin(
|
||||
id: string,
|
||||
request: CipherCollectionsRequest,
|
||||
): Promise<CipherMiniResponse>;
|
||||
abstract postPurgeCiphers(
|
||||
request: SecretVerificationRequest,
|
||||
organizationId?: string,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
NotificationResponse,
|
||||
} from "../../models/response/notification.response";
|
||||
import { EnvironmentService } from "../../platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { InsecureUrlNotAllowedError } from "../../services/api-errors";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
|
||||
|
||||
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
@@ -27,10 +29,14 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
async createHubConnection(token: string) {
|
||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||
if (!this.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||
throw new InsecureUrlNotAllowedError();
|
||||
}
|
||||
|
||||
this.anonHubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.url + "/anonymous-hub?Token=" + token, {
|
||||
|
||||
@@ -166,7 +166,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
|
||||
if (!policy?.enabled || policy?.data == null) {
|
||||
return null;
|
||||
}
|
||||
const data = policy.data?.defaultUriMatchStrategy;
|
||||
const data = policy.data?.uriMatchDetection;
|
||||
// Validate that data is a valid UriMatchStrategy value
|
||||
return Object.values(UriMatchStrategy).includes(data) ? data : null;
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -23,7 +23,6 @@ export enum FeatureFlag {
|
||||
|
||||
/* Billing */
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
@@ -31,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",
|
||||
@@ -38,6 +38,7 @@ export enum FeatureFlag {
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
|
||||
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
|
||||
@@ -56,6 +57,7 @@ export enum FeatureFlag {
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -103,13 +105,13 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
@@ -117,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,
|
||||
@@ -124,6 +127,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.WindowsBiometricsV2]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { InsecureUrlNotAllowedError } from "../../../services/api-errors";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
|
||||
// 2 Minutes
|
||||
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
|
||||
@@ -69,12 +71,17 @@ export class SignalRConnectionService {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly logService: LogService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
|
||||
new HubConnectionBuilder(),
|
||||
private readonly timeoutManager: TimeoutManager = globalThis,
|
||||
) {}
|
||||
|
||||
connect$(userId: UserId, notificationsUrl: string) {
|
||||
if (!notificationsUrl.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||
throw new InsecureUrlNotAllowedError();
|
||||
}
|
||||
|
||||
return new Observable<SignalRNotification>((subsciber) => {
|
||||
const connection = this.hubConnectionBuilderFactory()
|
||||
.withUrl(notificationsUrl + "/hub", {
|
||||
|
||||
9
libs/common/src/services/api-errors.ts
Normal file
9
libs/common/src/services/api-errors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class InsecureUrlNotAllowedError extends Error {
|
||||
constructor(url?: string) {
|
||||
if (url === undefined) {
|
||||
super("Insecure URL not allowed. All URLs must use HTTPS.");
|
||||
} else {
|
||||
super(`Insecure URL not allowed: ${url}. All URLs must use HTTPS.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Environment, EnvironmentService } from "../platform/abstractions/enviro
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||
|
||||
import { InsecureUrlNotAllowedError } from "./api-errors";
|
||||
import { ApiService, HttpOperations } from "./api.service";
|
||||
|
||||
describe("ApiService", () => {
|
||||
@@ -411,4 +412,39 @@ describe("ApiService", () => {
|
||||
).rejects.toMatchObject(error);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws error when trying to fetch an insecure URL", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "http://example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal ?? undefined,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 204,
|
||||
headers: new Headers(),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null),
|
||||
).rejects.toThrow(InsecureUrlNotAllowedError);
|
||||
expect(nativeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response
|
||||
import { CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||
|
||||
import { InsecureUrlNotAllowedError } from "./api-errors";
|
||||
|
||||
export type HttpOperations = {
|
||||
createRequest: (url: string, request: RequestInit) => Request;
|
||||
};
|
||||
@@ -1310,6 +1312,10 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||
throw new InsecureUrlNotAllowedError();
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
request.headers.set("Cache-Control", "no-store");
|
||||
request.headers.set("Pragma", "no-cache");
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
55
libs/common/src/vault/abstractions/cipher-risk.service.ts
Normal file
55
libs/common/src/vault/abstractions/cipher-risk.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CipherRiskResult,
|
||||
CipherRiskOptions,
|
||||
ExposedPasswordResult,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
export abstract class CipherRiskService {
|
||||
/**
|
||||
* Compute password risks for multiple ciphers.
|
||||
* Only processes Login ciphers with passwords.
|
||||
*
|
||||
* @param ciphers - The ciphers to evaluate for password risks
|
||||
* @param userId - The user ID for SDK client context
|
||||
* @param options - Optional configuration for risk computation (passwordMap, checkExposed)
|
||||
* @returns Array of CipherRisk results from SDK containing password_strength, exposed_result, and reuse_count
|
||||
*/
|
||||
abstract computeRiskForCiphers(
|
||||
ciphers: CipherView[],
|
||||
userId: UserId,
|
||||
options?: CipherRiskOptions,
|
||||
): Promise<CipherRiskResult[]>;
|
||||
|
||||
/**
|
||||
* Compute password risk for a single cipher by its ID. Will automatically build a password reuse map
|
||||
* from all the user's ciphers via the CipherService.
|
||||
* @param cipherId
|
||||
* @param userId
|
||||
* @param checkExposed - Whether to check if the password has been exposed in data breaches via HIBP
|
||||
* @returns CipherRisk result from SDK containing password_strength, exposed_result, and reuse_count
|
||||
*/
|
||||
abstract computeCipherRiskForUser(
|
||||
cipherId: CipherId,
|
||||
userId: UserId,
|
||||
checkExposed?: boolean,
|
||||
): Promise<CipherRiskResult>;
|
||||
|
||||
/**
|
||||
* Build a password reuse map for the given ciphers.
|
||||
* Maps each password to the number of times it appears across ciphers.
|
||||
* Only processes Login ciphers with passwords.
|
||||
*
|
||||
* @param ciphers - The ciphers to analyze for password reuse
|
||||
* @param userId - The user ID for SDK client context
|
||||
* @returns A map of password to count of occurrences
|
||||
*/
|
||||
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
|
||||
}
|
||||
|
||||
// Re-export SDK types for convenience
|
||||
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
|
||||
@@ -14,6 +14,11 @@ import { SshKeyApi } from "../api/ssh-key.api";
|
||||
import { AttachmentResponse } from "./attachment.response";
|
||||
import { PasswordHistoryResponse } from "./password-history.response";
|
||||
|
||||
export type CipherMiniResponse = Omit<
|
||||
CipherResponse,
|
||||
"edit" | "viewPassword" | "folderId" | "favorite" | "permissions"
|
||||
>;
|
||||
|
||||
export class CipherResponse extends BaseResponse {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
|
||||
@@ -1117,7 +1117,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher> {
|
||||
const request = new CipherCollectionsRequest(cipher.collectionIds);
|
||||
const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
|
||||
const data = new CipherData(response);
|
||||
// The response will be incomplete with several properties missing values
|
||||
// We will assign those properties values so the SDK decryption can complete
|
||||
const completedResponse = new CipherResponse(response);
|
||||
completedResponse.edit = true;
|
||||
completedResponse.viewPassword = true;
|
||||
completedResponse.favorite = false;
|
||||
const data = new CipherData(completedResponse);
|
||||
return new Cipher(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherService } from "../abstractions/cipher.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { LoginView } from "../models/view/login.view";
|
||||
|
||||
import { DefaultCipherRiskService } from "./default-cipher-risk.service";
|
||||
|
||||
describe("DefaultCipherRiskService", () => {
|
||||
let cipherRiskService: DefaultCipherRiskService;
|
||||
let sdkService: MockSdkService;
|
||||
let mockCipherService: jest.Mocked<CipherService>;
|
||||
|
||||
const mockUserId = "test-user-id" as UserId;
|
||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
|
||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
|
||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
|
||||
|
||||
beforeEach(() => {
|
||||
sdkService = new MockSdkService();
|
||||
mockCipherService = mock<CipherService>();
|
||||
cipherRiskService = new DefaultCipherRiskService(sdkService, mockCipherService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("computeRiskForCiphers", () => {
|
||||
it("should call SDK cipher_risk().compute_risk() with correct parameters", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockRiskResults: CipherRiskResult[] = [
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 3,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults);
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "test-password";
|
||||
cipher.login.username = "test@example.com";
|
||||
|
||||
const options: CipherRiskOptions = {
|
||||
checkExposed: true,
|
||||
passwordMap: undefined,
|
||||
hibpBaseUrl: undefined,
|
||||
};
|
||||
|
||||
const results = await cipherRiskService.computeRiskForCiphers([cipher], mockUserId, options);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
id: expect.anything(),
|
||||
password: "test-password",
|
||||
username: "test@example.com",
|
||||
},
|
||||
],
|
||||
options,
|
||||
);
|
||||
expect(results).toEqual(mockRiskResults);
|
||||
});
|
||||
|
||||
it("should filter out non-Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const loginCipher = new CipherView();
|
||||
loginCipher.id = mockCipherId1;
|
||||
loginCipher.type = CipherType.Login;
|
||||
loginCipher.login = new LoginView();
|
||||
loginCipher.login.password = "password1";
|
||||
|
||||
const cardCipher = new CipherView();
|
||||
cardCipher.id = mockCipherId2;
|
||||
cardCipher.type = CipherType.Card;
|
||||
|
||||
const identityCipher = new CipherView();
|
||||
identityCipher.id = mockCipherId3;
|
||||
identityCipher.type = CipherType.Identity;
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers(
|
||||
[loginCipher, cardCipher, identityCipher],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out Login ciphers without passwords", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const cipherWithPassword = new CipherView();
|
||||
cipherWithPassword.id = mockCipherId1;
|
||||
cipherWithPassword.type = CipherType.Login;
|
||||
cipherWithPassword.login = new LoginView();
|
||||
cipherWithPassword.login.password = "password1";
|
||||
|
||||
const cipherWithoutPassword = new CipherView();
|
||||
cipherWithoutPassword.id = mockCipherId2;
|
||||
cipherWithoutPassword.type = CipherType.Login;
|
||||
cipherWithoutPassword.login = new LoginView();
|
||||
cipherWithoutPassword.login.password = undefined;
|
||||
|
||||
const cipherWithEmptyPassword = new CipherView();
|
||||
cipherWithEmptyPassword.id = mockCipherId3;
|
||||
cipherWithEmptyPassword.type = CipherType.Login;
|
||||
cipherWithEmptyPassword.login = new LoginView();
|
||||
cipherWithEmptyPassword.login.password = "";
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers(
|
||||
[cipherWithPassword, cipherWithoutPassword, cipherWithEmptyPassword],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array when no valid Login ciphers provided", async () => {
|
||||
const cardCipher = new CipherView();
|
||||
cardCipher.type = CipherType.Card;
|
||||
|
||||
const results = await cipherRiskService.computeRiskForCiphers([cardCipher], mockUserId);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockRiskResults: CipherRiskResult[] = [
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 3,
|
||||
exposed_result: { type: "Found", value: 5 },
|
||||
reuse_count: 2,
|
||||
},
|
||||
{
|
||||
id: mockCipherId2 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults);
|
||||
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
cipher1.login.username = "user1@example.com";
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = mockCipherId2;
|
||||
cipher2.type = CipherType.Login;
|
||||
cipher2.login = new LoginView();
|
||||
cipher2.login.password = "password2";
|
||||
cipher2.login.username = "user2@example.com";
|
||||
|
||||
const results = await cipherRiskService.computeRiskForCiphers([cipher1, cipher2], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({ password: "password1", username: "user1@example.com" }),
|
||||
expect.objectContaining({ password: "password2", username: "user2@example.com" }),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(results).toEqual(mockRiskResults);
|
||||
});
|
||||
|
||||
it("should use default options when options not provided", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "test-password";
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([cipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
|
||||
checkExposed: false,
|
||||
passwordMap: undefined,
|
||||
hibpBaseUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle ciphers without username", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "test-password";
|
||||
cipher.login.username = undefined;
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([cipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
password: "test-password",
|
||||
username: undefined,
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordReuseMap", () => {
|
||||
it("should call SDK cipher_risk().password_reuse_map() with correct parameters", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockReuseMap = {
|
||||
password1: 2,
|
||||
password2: 1,
|
||||
};
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = mockCipherId2;
|
||||
cipher2.type = CipherType.Login;
|
||||
cipher2.login = new LoginView();
|
||||
cipher2.login.password = "password2";
|
||||
|
||||
const result = await cipherRiskService.buildPasswordReuseMap([cipher1, cipher2], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
expect.objectContaining({ password: "password2" }),
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCipherRiskForUser", () => {
|
||||
it("should compute risk for a single cipher with password reuse map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
// Setup cipher data
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
cipher1.login.username = "user1@example.com";
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = mockCipherId2;
|
||||
cipher2.type = CipherType.Login;
|
||||
cipher2.login = new LoginView();
|
||||
cipher2.login.password = "password1"; // Same password as cipher1
|
||||
cipher2.login.username = "user2@example.com";
|
||||
|
||||
const allCiphers = [cipher1, cipher2];
|
||||
|
||||
// Mock cipherViews$ observable
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject(allCiphers));
|
||||
|
||||
// Mock password reuse map
|
||||
const mockReuseMap = { password1: 2 };
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
// Mock compute_risk result
|
||||
const mockRiskResult: CipherRiskResult = {
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 3,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 2,
|
||||
};
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([mockRiskResult]);
|
||||
|
||||
const result = await cipherRiskService.computeCipherRiskForUser(
|
||||
asUuid<CipherId>(mockCipherId1),
|
||||
mockUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
// Verify cipherViews$ was called
|
||||
expect(mockCipherService.cipherViews$).toHaveBeenCalledWith(mockUserId);
|
||||
|
||||
// Verify password_reuse_map was called with all ciphers
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1", username: "user1@example.com" }),
|
||||
expect.objectContaining({ password: "password1", username: "user2@example.com" }),
|
||||
]);
|
||||
|
||||
// Verify compute_risk was called with target cipher and password map
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ password: "password1", username: "user1@example.com" })],
|
||||
{
|
||||
passwordMap: mockReuseMap,
|
||||
checkExposed: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockRiskResult);
|
||||
});
|
||||
|
||||
it("should throw error when cipher is not found", async () => {
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher1]));
|
||||
|
||||
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||
await expect(
|
||||
cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(nonExistentId), mockUserId),
|
||||
).rejects.toThrow(`Cipher with id ${asUuid<CipherId>(nonExistentId)} not found`);
|
||||
});
|
||||
|
||||
it("should use checkExposed parameter correctly", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(
|
||||
asUuid<CipherId>(mockCipherId1),
|
||||
mockUserId,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
|
||||
passwordMap: expect.any(Object),
|
||||
checkExposed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default checkExposed to true when not provided", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "Found", value: 10 },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
|
||||
passwordMap: expect.any(Object),
|
||||
checkExposed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle ciphers without passwords when building password map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipherWithPassword = new CipherView();
|
||||
cipherWithPassword.id = mockCipherId1;
|
||||
cipherWithPassword.type = CipherType.Login;
|
||||
cipherWithPassword.login = new LoginView();
|
||||
cipherWithPassword.login.password = "password1";
|
||||
|
||||
const cipherWithoutPassword = new CipherView();
|
||||
cipherWithoutPassword.id = mockCipherId2;
|
||||
cipherWithoutPassword.type = CipherType.Login;
|
||||
cipherWithoutPassword.login = new LoginView();
|
||||
cipherWithoutPassword.login.password = "";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
new BehaviorSubject([cipherWithPassword, cipherWithoutPassword]),
|
||||
);
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Verify password_reuse_map only received cipher with password
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle non-Login ciphers in vault when building password map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const loginCipher = new CipherView();
|
||||
loginCipher.id = mockCipherId1;
|
||||
loginCipher.type = CipherType.Login;
|
||||
loginCipher.login = new LoginView();
|
||||
loginCipher.login.password = "password1";
|
||||
|
||||
const cardCipher = new CipherView();
|
||||
cardCipher.id = mockCipherId2;
|
||||
cardCipher.type = CipherType.Card;
|
||||
|
||||
const noteCipher = new CipherView();
|
||||
noteCipher.id = mockCipherId3;
|
||||
noteCipher.type = CipherType.SecureNote;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
new BehaviorSubject([loginCipher, cardCipher, noteCipher]),
|
||||
);
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Verify password_reuse_map only received Login cipher
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should compute fresh password map on each call", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({ password1: 1 });
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// First call
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Second call
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Verify password_reuse_map was called twice (fresh computation each time)
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
115
libs/common/src/vault/services/default-cipher-risk.service.ts
Normal file
115
libs/common/src/vault/services/default-cipher-risk.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import {
|
||||
CipherLoginDetails,
|
||||
CipherRiskOptions,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
CipherRiskResult,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
constructor(
|
||||
private sdkService: SdkService,
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
async computeRiskForCiphers(
|
||||
ciphers: CipherView[],
|
||||
userId: UserId,
|
||||
options?: CipherRiskOptions,
|
||||
): Promise<CipherRiskResult[]> {
|
||||
const loginDetails = this.mapToLoginDetails(ciphers);
|
||||
|
||||
if (loginDetails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
using ref = sdk.take();
|
||||
const cipherRiskClient = ref.value.vault().cipher_risk();
|
||||
return await cipherRiskClient.compute_risk(
|
||||
loginDetails,
|
||||
options ?? { checkExposed: false },
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async computeCipherRiskForUser(
|
||||
cipherId: CipherId,
|
||||
userId: UserId,
|
||||
checkExposed: boolean = true,
|
||||
): Promise<CipherRiskResult> {
|
||||
// Get all ciphers for the user
|
||||
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
||||
|
||||
// Find the specific cipher
|
||||
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
|
||||
if (!targetCipher) {
|
||||
throw new Error(`Cipher with id ${cipherId} not found`);
|
||||
}
|
||||
|
||||
// Build fresh password reuse map from all ciphers
|
||||
const passwordMap = await this.buildPasswordReuseMap(allCiphers, userId);
|
||||
|
||||
// Call existing computeRiskForCiphers with single cipher and map
|
||||
const results = await this.computeRiskForCiphers([targetCipher], userId, {
|
||||
passwordMap,
|
||||
checkExposed,
|
||||
});
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap> {
|
||||
const loginDetails = this.mapToLoginDetails(ciphers);
|
||||
|
||||
if (loginDetails.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
using ref = sdk.take();
|
||||
const cipherRiskClient = ref.value.vault().cipher_risk();
|
||||
return cipherRiskClient.password_reuse_map(loginDetails);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView array to CipherLoginDetails array for SDK consumption.
|
||||
* Only includes Login ciphers with non-empty passwords.
|
||||
*/
|
||||
private mapToLoginDetails(ciphers: CipherView[]): CipherLoginDetails[] {
|
||||
return ciphers
|
||||
.filter((cipher) => {
|
||||
return (
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login?.password != null &&
|
||||
cipher.login.password !== ""
|
||||
);
|
||||
})
|
||||
.map(
|
||||
(cipher) =>
|
||||
({
|
||||
id: asUuid<CipherId>(cipher.id),
|
||||
password: cipher.login.password!,
|
||||
username: cipher.login.username,
|
||||
}) satisfies CipherLoginDetails,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,6 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
"hover:!tw-text-muted",
|
||||
"aria-disabled:tw-cursor-not-allowed",
|
||||
"hover:tw-no-underline",
|
||||
"aria-disabled:tw-pointer-events-none",
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { BaseCardDirective } from "./base-card.directive";
|
||||
* The base card component is a container that applies our standard card border and box-shadow.
|
||||
* In most cases using our `<bit-card>` component should suffice.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-base-card",
|
||||
template: `<ng-content></ng-content>`,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-card-content",
|
||||
template: `<div class="tw-p-4 [@media(min-width:650px)]:tw-p-6"><ng-content></ng-content></div>`,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label"
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
import { SpinnerComponent } from "../spinner";
|
||||
import { TooltipDirective } from "../tooltip";
|
||||
import { ariaDisableElement } from "../utils";
|
||||
|
||||
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
|
||||
@@ -100,7 +101,10 @@ const sizes: Record<IconButtonSize, string[]> = {
|
||||
*/
|
||||
"[attr.bitIconButton]": "icon()",
|
||||
},
|
||||
hostDirectives: [AriaDisableDirective],
|
||||
hostDirectives: [
|
||||
AriaDisableDirective,
|
||||
{ directive: TooltipDirective, inputs: ["tooltipPosition"] },
|
||||
],
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
readonly icon = model.required<string>({ alias: "bitIconButton" });
|
||||
@@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
|
||||
readonly size = model<IconButtonSize>("default");
|
||||
|
||||
private elementRef = inject(ElementRef);
|
||||
private tooltip = inject(TooltipDirective, { host: true, optional: true });
|
||||
|
||||
/**
|
||||
* label input will be used to set the `aria-label` attributes on the button.
|
||||
* This is for accessibility purposes, as it provides a text alternative for the icon button.
|
||||
@@ -186,8 +193,6 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
return this.elementRef.nativeElement;
|
||||
}
|
||||
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
constructor() {
|
||||
const element = this.elementRef.nativeElement;
|
||||
|
||||
@@ -198,9 +203,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
effect(() => {
|
||||
setA11yTitleAndAriaLabel({
|
||||
element: this.elementRef.nativeElement,
|
||||
title: originalTitle ?? this.label(),
|
||||
title: undefined,
|
||||
label: this.label(),
|
||||
});
|
||||
|
||||
const tooltipContent: string = originalTitle || this.label();
|
||||
|
||||
if (tooltipContent) {
|
||||
this.tooltip?.tooltipContent.set(tooltipContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./toast";
|
||||
export * from "./toggle-group";
|
||||
export * from "./tooltip";
|
||||
export * from "./typography";
|
||||
export * from "./utils";
|
||||
export * from "./stepper";
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
[routerLinkActiveOptions]="routerLinkActiveOptions()"
|
||||
(mainContentClicked)="handleMainContentClicked()"
|
||||
[ariaLabel]="ariaLabel()"
|
||||
[hideActiveStyles]="parentHideActiveStyles"
|
||||
[hideActiveStyles]="parentHideActiveStyles()"
|
||||
[ariaCurrentWhenActive]="ariaCurrent()"
|
||||
>
|
||||
<ng-template #button>
|
||||
<button
|
||||
@@ -18,7 +19,6 @@
|
||||
[buttonType]="'nav-contrast'"
|
||||
(click)="toggle($event)"
|
||||
size="small"
|
||||
aria-haspopup="true"
|
||||
[attr.aria-expanded]="open().toString()"
|
||||
[attr.aria-controls]="contentId"
|
||||
[label]="['toggleCollapse' | i18n, text()].join(' ')"
|
||||
@@ -30,7 +30,7 @@
|
||||
</ng-container>
|
||||
</bit-nav-item>
|
||||
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
|
||||
@if (sideNavService.open$ | async) {
|
||||
@if (sideNavOpen()) {
|
||||
@if (open()) {
|
||||
<div
|
||||
[attr.id]="contentId"
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
input,
|
||||
model,
|
||||
contentChildren,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -33,10 +36,33 @@ import { SideNavService } from "./side-nav.service";
|
||||
export class NavGroupComponent extends NavBaseComponent {
|
||||
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: true });
|
||||
|
||||
readonly sideNavOpen = toSignal(this.sideNavService.open$);
|
||||
|
||||
readonly sideNavAndGroupOpen = computed(() => {
|
||||
return this.open() && this.sideNavOpen();
|
||||
});
|
||||
|
||||
/** When the side nav is open, the parent nav item should not show active styles when open. */
|
||||
protected get parentHideActiveStyles(): boolean {
|
||||
return this.hideActiveStyles() || (this.open() && this.sideNavService.open);
|
||||
}
|
||||
readonly parentHideActiveStyles = computed(() => {
|
||||
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
|
||||
});
|
||||
|
||||
/**
|
||||
* Allow overriding of the RouterLink['ariaCurrentWhenActive'] property.
|
||||
*
|
||||
* By default, assuming that the nav group navigates to its first child page instead of its
|
||||
* own page, the nav group will be `current` when the side nav is collapsed or the nav group
|
||||
* is collapsed (since child pages don't show in either collapsed view) and not `current`
|
||||
* when the side nav and nav group are open (since the child page will show as `current`).
|
||||
*
|
||||
* If the nav group navigates to its own page, use this property to always set it to announce
|
||||
* as `current` by passing in `"page"`.
|
||||
*/
|
||||
readonly ariaCurrentWhenActive = input<RouterLinkActive["ariaCurrentWhenActive"]>();
|
||||
|
||||
readonly ariaCurrent = computed(() => {
|
||||
return this.ariaCurrentWhenActive() ?? (this.sideNavAndGroupOpen() ? undefined : "page");
|
||||
});
|
||||
|
||||
/**
|
||||
* UID for `[attr.aria-controls]`
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
]"
|
||||
>
|
||||
<div class="tw-relative tw-flex tw-items-center tw-h-full">
|
||||
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
|
||||
<ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if a value was passed to `this.to` -->
|
||||
<!-- Show if a value was passed to `this.route` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
@@ -43,7 +43,7 @@
|
||||
[attr.aria-label]="ariaLabel() || text()"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="routerLinkActiveOptions()"
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
[ariaCurrentWhenActive]="ariaCurrentWhenActive()"
|
||||
(isActiveChange)="setIsActive($event)"
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
@@ -51,12 +51,13 @@
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.to` is falsy -->
|
||||
<!-- Show if `this.route` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-size-full tw-px-4 tw-pe-3 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
class="tw-size-full tw-px-4 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
[ngClass]="open ? 'tw-pe-3' : 'tw-pe-4'"
|
||||
data-fvw
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostListener, Optional, input } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { RouterLinkActive, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
@@ -39,6 +39,14 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
return this.forceActiveStyles() || (this._isActive && !this.hideActiveStyles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow overriding of the RouterLink['ariaCurrentWhenActive'] property.
|
||||
*
|
||||
* Useful for situations like nav-groups that navigate to their first child page and should
|
||||
* not be marked `current` while the child page is marked as `current`
|
||||
*/
|
||||
readonly ariaCurrentWhenActive = input<RouterLinkActive["ariaCurrentWhenActive"]>("page");
|
||||
|
||||
/**
|
||||
* The design spec calls for the an outline to wrap the entire element when the template's
|
||||
* anchor/button has :focus-visible. Usually, we would use :focus-within for this. However, that
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<div
|
||||
class="bit-tooltip-container"
|
||||
[attr.data-position]="tooltipData.tooltipPosition()"
|
||||
[attr.data-visible]="tooltipData.isVisible()"
|
||||
>
|
||||
<div role="tooltip" class="bit-tooltip">
|
||||
<ng-content>{{ tooltipData.content() }}</ng-content>
|
||||
@if (tooltipData.content()) {
|
||||
<div
|
||||
class="bit-tooltip-container"
|
||||
[attr.data-position]="tooltipData.tooltipPosition()"
|
||||
[attr.data-visible]="tooltipData.isVisible()"
|
||||
>
|
||||
<div role="tooltip" class="bit-tooltip" [id]="tooltipData.id()">
|
||||
<ng-content>{{ tooltipData.content() }}</ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type TooltipData = {
|
||||
content: Signal<string>;
|
||||
isVisible: Signal<boolean>;
|
||||
tooltipPosition: Signal<TooltipPosition>;
|
||||
id: Signal<string>;
|
||||
};
|
||||
|
||||
export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA");
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
ElementRef,
|
||||
Injector,
|
||||
input,
|
||||
effect,
|
||||
signal,
|
||||
model,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
|
||||
@@ -26,30 +27,39 @@ import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component";
|
||||
"(mouseleave)": "hideTooltip()",
|
||||
"(focus)": "showTooltip()",
|
||||
"(blur)": "hideTooltip()",
|
||||
"[attr.aria-describedby]": "resolvedDescribedByIds()",
|
||||
},
|
||||
})
|
||||
export class TooltipDirective implements OnInit {
|
||||
private static nextId = 0;
|
||||
/**
|
||||
* The value of this input is forwarded to the tooltip.component to render
|
||||
*/
|
||||
readonly bitTooltip = input.required<string>();
|
||||
readonly tooltipContent = model("", { alias: "bitTooltip" });
|
||||
/**
|
||||
* The value of this input is forwarded to the tooltip.component to set its position explicitly.
|
||||
* @default "above-center"
|
||||
*/
|
||||
readonly tooltipPosition = input<TooltipPositionIdentifier>("above-center");
|
||||
|
||||
/**
|
||||
* Input so the consumer can choose to add the tooltip id to the aria-describedby attribute of the host element.
|
||||
*/
|
||||
readonly addTooltipToDescribedby = input<boolean>(false);
|
||||
|
||||
private readonly isVisible = signal(false);
|
||||
private overlayRef: OverlayRef | undefined;
|
||||
private elementRef = inject(ElementRef);
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private overlay = inject(Overlay);
|
||||
private viewContainerRef = inject(ViewContainerRef);
|
||||
private injector = inject(Injector);
|
||||
private positionStrategy = this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(this.elementRef)
|
||||
.withFlexibleDimensions(false)
|
||||
.withPush(true);
|
||||
private tooltipId = `bit-tooltip-${TooltipDirective.nextId++}`;
|
||||
private currentDescribedByIds =
|
||||
this.elementRef.nativeElement.getAttribute("aria-describedby") || null;
|
||||
|
||||
private tooltipPortal = new ComponentPortal(
|
||||
TooltipComponent,
|
||||
@@ -59,23 +69,50 @@ export class TooltipDirective implements OnInit {
|
||||
{
|
||||
provide: TOOLTIP_DATA,
|
||||
useValue: {
|
||||
content: this.bitTooltip,
|
||||
content: this.tooltipContent,
|
||||
isVisible: this.isVisible,
|
||||
tooltipPosition: this.tooltipPosition,
|
||||
id: signal(this.tooltipId),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
private destroyTooltip = () => {
|
||||
this.overlayRef?.dispose();
|
||||
this.overlayRef = undefined;
|
||||
this.isVisible.set(false);
|
||||
};
|
||||
|
||||
private showTooltip = () => {
|
||||
if (!this.overlayRef) {
|
||||
this.overlayRef = this.overlay.create({
|
||||
...this.defaultPopoverConfig,
|
||||
positionStrategy: this.positionStrategy,
|
||||
});
|
||||
|
||||
this.overlayRef.attach(this.tooltipPortal);
|
||||
}
|
||||
this.isVisible.set(true);
|
||||
};
|
||||
|
||||
private hideTooltip = () => {
|
||||
this.isVisible.set(false);
|
||||
this.destroyTooltip();
|
||||
};
|
||||
|
||||
private readonly resolvedDescribedByIds = computed(() => {
|
||||
if (this.addTooltipToDescribedby()) {
|
||||
if (this.currentDescribedByIds) {
|
||||
return `${this.currentDescribedByIds || ""} ${this.tooltipId}`;
|
||||
} else {
|
||||
return this.tooltipId;
|
||||
}
|
||||
} else {
|
||||
return this.currentDescribedByIds;
|
||||
}
|
||||
});
|
||||
|
||||
private computePositions(tooltipPosition: TooltipPositionIdentifier) {
|
||||
const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition);
|
||||
|
||||
@@ -91,20 +128,5 @@ export class TooltipDirective implements OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
...this.defaultPopoverConfig,
|
||||
positionStrategy: this.positionStrategy,
|
||||
});
|
||||
|
||||
this.overlayRef.attach(this.tooltipPortal);
|
||||
|
||||
effect(
|
||||
() => {
|
||||
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
|
||||
this.overlayRef?.updatePosition();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,20 @@ import { TooltipDirective } from "@bitwarden/components";
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`
|
||||
### Tooltip usage
|
||||
|
||||
The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`.
|
||||
|
||||
The `IconButtonComponent` will automatically apply a tooltip based on the component's `label` input.
|
||||
|
||||
#### Adding the tooltip to the host element's `aria-describedby` list
|
||||
|
||||
The `addTooltipToDescribedby="true"` model input can be used to add the tooltip id to the list of
|
||||
the host element's `aria-describedby` element IDs.
|
||||
|
||||
NOTE: This behavior is not always necessary and could be redundant if the host element's aria
|
||||
attributes already convey the same message as the tooltip. Use only when the tooltip is extra,
|
||||
non-essential contextual information.
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
@@ -29,3 +42,7 @@ NOTE: The `TooltipComponent` can't be used on its own. It must be applied via th
|
||||
### On disabled element
|
||||
|
||||
<Canvas of={stories.OnDisabledButton} />
|
||||
|
||||
### On a Button
|
||||
|
||||
<Canvas of={stories.OnNonIconButton} />
|
||||
|
||||
@@ -59,7 +59,14 @@ describe("TooltipDirective (visibility only)", () => {
|
||||
};
|
||||
|
||||
const overlayRefStub: OverlayRefStub = {
|
||||
attach: jest.fn(() => ({})),
|
||||
attach: jest.fn(() => ({
|
||||
changeDetectorRef: { detectChanges: jest.fn() },
|
||||
location: {
|
||||
nativeElement: {
|
||||
querySelector: jest.fn().mockReturnValue({ id: "tip-123" }),
|
||||
},
|
||||
},
|
||||
})),
|
||||
updatePosition: jest.fn(),
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ type Story = StoryObj<TooltipDirective>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
bitTooltip: "This is a tooltip",
|
||||
tooltipPosition: "above-center",
|
||||
},
|
||||
render: (args) => ({
|
||||
@@ -81,6 +80,7 @@ export const Default: Story = {
|
||||
<div class="tw-p-4">
|
||||
<button
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
label="Your tooltip content here"
|
||||
${formatArgsForCodeSnippet<TooltipDirective>(args)}
|
||||
>
|
||||
Button label here
|
||||
@@ -98,26 +98,29 @@ export const Default: Story = {
|
||||
|
||||
export const AllPositions: Story = {
|
||||
render: () => ({
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
template: `
|
||||
<div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center">
|
||||
<button
|
||||
bitIconButton="bwi-angle-up"
|
||||
bitTooltip="Top tooltip"
|
||||
label="Top tooltip"
|
||||
tooltipPosition="above-center"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-angle-right"
|
||||
bitTooltip="Right tooltip"
|
||||
label="Right tooltip"
|
||||
tooltipPosition="right-center"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-angle-left"
|
||||
bitTooltip="Left tooltip"
|
||||
label="Left tooltip"
|
||||
tooltipPosition="left-center"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-angle-down"
|
||||
bitTooltip="Bottom tooltip"
|
||||
label="Bottom tooltip"
|
||||
tooltipPosition="below-center"
|
||||
></button>
|
||||
</div>
|
||||
@@ -127,11 +130,14 @@ export const AllPositions: Story = {
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => ({
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
template: `
|
||||
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
|
||||
<button
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
bitTooltip="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability."
|
||||
label="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability."
|
||||
></button>
|
||||
</div>
|
||||
`,
|
||||
@@ -140,14 +146,34 @@ export const LongContent: Story = {
|
||||
|
||||
export const OnDisabledButton: Story = {
|
||||
render: () => ({
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
template: `
|
||||
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
|
||||
<button
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
bitTooltip="Tooltip on disabled button"
|
||||
label="Tooltip on disabled button"
|
||||
[disabled]="true"
|
||||
></button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const OnNonIconButton: Story = {
|
||||
render: () => ({
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
template: `
|
||||
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
|
||||
<button
|
||||
bitButton
|
||||
addTooltipToDescribedby="true"
|
||||
bitTooltip="Some additional tooltip text to describe the button"
|
||||
>Button label</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -6,12 +6,15 @@ import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
export type FolderRelationship = [cipherIndex: number, folderIndex: number];
|
||||
export type CollectionRelationship = [cipherIndex: number, collectionIndex: number];
|
||||
|
||||
export class ImportResult {
|
||||
success = false;
|
||||
errorMessage: string;
|
||||
ciphers: CipherView[] = [];
|
||||
folders: FolderView[] = [];
|
||||
folderRelationships: [number, number][] = [];
|
||||
folderRelationships: FolderRelationship[] = [];
|
||||
collections: CollectionView[] = [];
|
||||
collectionRelationships: [number, number][] = [];
|
||||
collectionRelationships: CollectionRelationship[] = [];
|
||||
}
|
||||
|
||||
@@ -61,27 +61,28 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra
|
||||
importers: ImportersMetadata,
|
||||
type: ImportType,
|
||||
client: ClientType,
|
||||
enabled: boolean,
|
||||
withABESupport: boolean,
|
||||
): DataLoader[] | undefined {
|
||||
let loaders = availableLoaders(importers, type, client);
|
||||
let includeABE = false;
|
||||
|
||||
if (enabled && (type === "bravecsv" || type === "chromecsv" || type === "edgecsv")) {
|
||||
if (withABESupport) {
|
||||
return loaders;
|
||||
}
|
||||
|
||||
// Special handling for Brave, Chrome, and Edge CSV imports on Windows Desktop
|
||||
if (type === "bravecsv" || type === "chromecsv" || type === "edgecsv") {
|
||||
try {
|
||||
const device = this.system.environment.getDevice();
|
||||
const isWindowsDesktop = device === DeviceType.WindowsDesktop;
|
||||
if (isWindowsDesktop) {
|
||||
includeABE = true;
|
||||
// Exclude the Chromium loader if on Windows Desktop without ABE support
|
||||
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
|
||||
}
|
||||
} catch {
|
||||
includeABE = true;
|
||||
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
|
||||
}
|
||||
}
|
||||
|
||||
// If the browser is unsupported, remove the chromium loader
|
||||
if (!includeABE) {
|
||||
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
|
||||
}
|
||||
return loaders;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,25 @@ describe("ImportMetadataService", () => {
|
||||
|
||||
// Recreate the service with the updated mocks for logging tests
|
||||
sut = new DefaultImportMetadataService(systemServiceProvider);
|
||||
|
||||
// Set up importers to include bravecsv and chromecsv with chromium loader
|
||||
sut["importers"] = {
|
||||
chromecsv: {
|
||||
type: "chromecsv",
|
||||
loaders: [Loader.file, Loader.chromium],
|
||||
instructions: Instructions.chromium,
|
||||
},
|
||||
bravecsv: {
|
||||
type: "bravecsv",
|
||||
loaders: [Loader.file, Loader.chromium],
|
||||
instructions: Instructions.chromium,
|
||||
},
|
||||
edgecsv: {
|
||||
type: "edgecsv",
|
||||
loaders: [Loader.file, Loader.chromium],
|
||||
instructions: Instructions.chromium,
|
||||
},
|
||||
} as ImportersMetadata;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -112,6 +131,7 @@ describe("ImportMetadataService", () => {
|
||||
});
|
||||
|
||||
it("should update when feature flag changes", async () => {
|
||||
environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader
|
||||
const emissions: ImporterMetadata[] = [];
|
||||
|
||||
@@ -126,13 +146,15 @@ describe("ImportMetadataService", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(emissions).toHaveLength(2);
|
||||
// Disable ABE - chromium loader should be excluded
|
||||
expect(emissions[0].loaders).not.toContain(Loader.chromium);
|
||||
expect(emissions[1].loaders).toContain(Loader.file);
|
||||
// Enabled ABE - chromium loader should be included
|
||||
expect(emissions[1].loaders).toContain(Loader.chromium);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should exclude chromium loader when ABE is disabled but on Windows Desktop", async () => {
|
||||
it("should exclude chromium loader when ABE is disabled and on Windows Desktop", async () => {
|
||||
environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
|
||||
featureFlagSubject.next(false);
|
||||
@@ -146,10 +168,12 @@ describe("ImportMetadataService", () => {
|
||||
expect(result.loaders).toContain(Loader.file);
|
||||
});
|
||||
|
||||
it("should exclude chromium loader when ABE is enabled but not on Windows Desktop", async () => {
|
||||
environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
|
||||
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
|
||||
featureFlagSubject.next(true);
|
||||
it("should exclude chromium loader when ABE is disabled and getDevice throws error", async () => {
|
||||
environment.getDevice.mockImplementation(() => {
|
||||
throw new Error("Device detection failed");
|
||||
});
|
||||
const testType: ImportType = "bravecsv";
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
|
||||
typeSubject.next(testType);
|
||||
@@ -160,17 +184,22 @@ describe("ImportMetadataService", () => {
|
||||
expect(result.loaders).toContain(Loader.file);
|
||||
});
|
||||
|
||||
it("should include chromium loader when ABE is enabled and on Windows Desktop", async () => {
|
||||
// Set up importers to include bravecsv with chromium loader
|
||||
sut["importers"] = {
|
||||
bravecsv: {
|
||||
type: "bravecsv",
|
||||
loaders: [Loader.file, Loader.chromium],
|
||||
instructions: Instructions.chromium,
|
||||
},
|
||||
} as ImportersMetadata;
|
||||
it("should include chromium loader when ABE is disabled and not on Windows Desktop", async () => {
|
||||
environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
|
||||
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
|
||||
typeSubject.next(testType);
|
||||
|
||||
const result = await metadataPromise;
|
||||
|
||||
expect(result.loaders).toContain(Loader.chromium);
|
||||
expect(result.loaders).toContain(Loader.file);
|
||||
});
|
||||
|
||||
it("should include chromium loader when ABE is enabled regardless of device", async () => {
|
||||
environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
|
||||
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
|
||||
featureFlagSubject.next(true);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
@@ -194,7 +198,7 @@ describe("ImportService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passing importTarget as null on setImportTarget with organizationId throws error", async () => {
|
||||
it("passing importTarget as undefined on setImportTarget with organizationId throws error", async () => {
|
||||
const setImportTargetMethod = importService["setImportTarget"](
|
||||
null,
|
||||
organizationId,
|
||||
@@ -204,10 +208,10 @@ describe("ImportService", () => {
|
||||
await expect(setImportTargetMethod).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("passing importTarget as null on setImportTarget throws error", async () => {
|
||||
it("passing importTarget as undefined on setImportTarget throws error", async () => {
|
||||
const setImportTargetMethod = importService["setImportTarget"](
|
||||
null,
|
||||
"",
|
||||
undefined,
|
||||
new Object() as CollectionView,
|
||||
);
|
||||
|
||||
@@ -239,11 +243,40 @@ describe("ImportService", () => {
|
||||
importResult.ciphers.push(createCipher({ name: "cipher2" }));
|
||||
importResult.folderRelationships.push([0, 0]);
|
||||
|
||||
await importService["setImportTarget"](importResult, "", mockImportTargetFolder);
|
||||
await importService["setImportTarget"](importResult, undefined, mockImportTargetFolder);
|
||||
expect(importResult.folderRelationships.length).toEqual(2);
|
||||
expect(importResult.folderRelationships[0]).toEqual([1, 0]);
|
||||
expect(importResult.folderRelationships[1]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("If importTarget is of type DefaultUserCollection sets it as new root for all ciphers as nesting is not supported", async () => {
|
||||
importResult.collections.push(mockCollection1);
|
||||
importResult.collections.push(mockCollection2);
|
||||
importResult.ciphers.push(createCipher({ name: "cipher1" }));
|
||||
importResult.ciphers.push(createCipher({ name: "cipher2" }));
|
||||
importResult.ciphers.push(createCipher({ name: "cipher3" }));
|
||||
|
||||
importResult.collectionRelationships.push([0, 0]);
|
||||
importResult.collectionRelationships.push([1, 1]);
|
||||
importResult.collectionRelationships.push([2, 0]);
|
||||
|
||||
mockImportTargetCollection.type = CollectionTypes.DefaultUserCollection;
|
||||
await importService["setImportTarget"](
|
||||
importResult,
|
||||
organizationId,
|
||||
mockImportTargetCollection,
|
||||
);
|
||||
expect(importResult.collections.length).toBe(1);
|
||||
expect(importResult.collections[0]).toBe(mockImportTargetCollection);
|
||||
|
||||
expect(importResult.collectionRelationships.length).toEqual(3);
|
||||
expect(importResult.collectionRelationships[0]).toEqual([0, 0]);
|
||||
expect(importResult.collectionRelationships[1]).toEqual([1, 0]);
|
||||
expect(importResult.collectionRelationships[2]).toEqual([2, 0]);
|
||||
|
||||
expect(importResult.collectionRelationships.map((r) => r[0])).toEqual([0, 1, 2]);
|
||||
expect(importResult.collectionRelationships.every((r) => r[1] === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CollectionService,
|
||||
CollectionWithIdRequest,
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -101,7 +102,7 @@ import {
|
||||
ImportType,
|
||||
regularImportOptions,
|
||||
} from "../models/import-options";
|
||||
import { ImportResult } from "../models/import-result";
|
||||
import { CollectionRelationship, FolderRelationship, ImportResult } from "../models/import-result";
|
||||
import { ImportApiServiceAbstraction } from "../services/import-api.service.abstraction";
|
||||
import { ImportServiceAbstraction } from "../services/import.service.abstraction";
|
||||
|
||||
@@ -473,19 +474,20 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
|
||||
private async setImportTarget(
|
||||
importResult: ImportResult,
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId | undefined,
|
||||
importTarget: FolderView | CollectionView,
|
||||
) {
|
||||
if (!importTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Importing into an organization
|
||||
if (organizationId) {
|
||||
if (!(importTarget instanceof CollectionView)) {
|
||||
throw new Error(this.i18nService.t("errorAssigningTargetCollection"));
|
||||
}
|
||||
|
||||
const noCollectionRelationShips: [number, number][] = [];
|
||||
const noCollectionRelationShips: CollectionRelationship[] = [];
|
||||
importResult.ciphers.forEach((c, index) => {
|
||||
if (
|
||||
!Array.isArray(importResult.collectionRelationships) ||
|
||||
@@ -495,15 +497,28 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
}
|
||||
});
|
||||
|
||||
const collections: CollectionView[] = [...importResult.collections];
|
||||
importResult.collections = [importTarget as CollectionView];
|
||||
// My Items collections do not support collection nesting.
|
||||
// Flatten all ciphers from nested collections into the import target.
|
||||
if (importTarget.type === CollectionTypes.DefaultUserCollection) {
|
||||
importResult.collections = [importTarget];
|
||||
|
||||
const flattenRelationships: CollectionRelationship[] = [];
|
||||
importResult.ciphers.forEach((c, index) => {
|
||||
flattenRelationships.push([index, 0]);
|
||||
});
|
||||
importResult.collectionRelationships = flattenRelationships;
|
||||
return;
|
||||
}
|
||||
|
||||
const collections = [...importResult.collections];
|
||||
importResult.collections = [importTarget];
|
||||
collections.map((x) => {
|
||||
const f = new CollectionView(x);
|
||||
f.name = `${importTarget.name}/${x.name}`;
|
||||
importResult.collections.push(f);
|
||||
});
|
||||
|
||||
const relationships: [number, number][] = [...importResult.collectionRelationships];
|
||||
const relationships = [...importResult.collectionRelationships];
|
||||
importResult.collectionRelationships = [...noCollectionRelationShips];
|
||||
relationships.map((x) => {
|
||||
importResult.collectionRelationships.push([x[0], x[1] + 1]);
|
||||
@@ -512,11 +527,12 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
|
||||
// Importing into personal vault
|
||||
if (!(importTarget instanceof FolderView)) {
|
||||
throw new Error(this.i18nService.t("errorAssigningTargetFolder"));
|
||||
}
|
||||
|
||||
const noFolderRelationShips: [number, number][] = [];
|
||||
const noFolderRelationShips: FolderRelationship[] = [];
|
||||
importResult.ciphers.forEach((c, index) => {
|
||||
if (Utils.isNullOrEmpty(c.folderId)) {
|
||||
c.folderId = importTarget.id;
|
||||
@@ -524,8 +540,8 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
}
|
||||
});
|
||||
|
||||
const folders: FolderView[] = [...importResult.folders];
|
||||
importResult.folders = [importTarget as FolderView];
|
||||
const folders = [...importResult.folders];
|
||||
importResult.folders = [importTarget];
|
||||
folders.map((x) => {
|
||||
const newFolderName = `${importTarget.name}/${x.name}`;
|
||||
const f = new FolderView();
|
||||
@@ -533,7 +549,7 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
importResult.folders.push(f);
|
||||
});
|
||||
|
||||
const relationships: [number, number][] = [...importResult.folderRelationships];
|
||||
const relationships = [...importResult.folderRelationships];
|
||||
importResult.folderRelationships = [...noFolderRelationShips];
|
||||
relationships.map((x) => {
|
||||
importResult.folderRelationships.push([x[0], x[1] + 1]);
|
||||
|
||||
@@ -107,6 +107,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", {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { ExportedVault } from "../types";
|
||||
@@ -5,6 +7,24 @@ import { ExportedVault } from "../types";
|
||||
export const EXPORT_FORMATS = ["csv", "json", "encrypted_json", "zip"] as const;
|
||||
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* Options that determine which export formats are available
|
||||
*/
|
||||
export type FormatOptions = {
|
||||
/** Whether the export is for the user's personal vault */
|
||||
isMyVault: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata describing an available export format
|
||||
*/
|
||||
export type ExportFormatMetadata = {
|
||||
/** Display name for the format (e.g., ".json", ".csv") */
|
||||
name: string;
|
||||
/** The export format identifier */
|
||||
format: ExportFormat;
|
||||
};
|
||||
|
||||
export abstract class VaultExportServiceAbstraction {
|
||||
abstract getExport: (
|
||||
userId: UserId,
|
||||
@@ -18,4 +38,11 @@ export abstract class VaultExportServiceAbstraction {
|
||||
password: string,
|
||||
onlyManagedCollections?: boolean,
|
||||
) => Promise<ExportedVault>;
|
||||
|
||||
/**
|
||||
* Get available export formats based on vault context
|
||||
* @param options Options determining which formats are available
|
||||
* @returns Observable stream of available export formats
|
||||
*/
|
||||
abstract formats$(options: FormatOptions): Observable<ExportFormatMetadata[]>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, Observable, of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -9,7 +9,12 @@ import { ExportedVault } from "../types";
|
||||
|
||||
import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction";
|
||||
import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction";
|
||||
import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction";
|
||||
import {
|
||||
ExportFormat,
|
||||
ExportFormatMetadata,
|
||||
FormatOptions,
|
||||
VaultExportServiceAbstraction,
|
||||
} from "./vault-export.service.abstraction";
|
||||
|
||||
export class VaultExportService implements VaultExportServiceAbstraction {
|
||||
constructor(
|
||||
@@ -85,6 +90,26 @@ export class VaultExportService implements VaultExportServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available export formats based on vault context
|
||||
* @param options Options determining which formats are available
|
||||
* @returns Observable stream of available export formats
|
||||
*/
|
||||
formats$(options: FormatOptions): Observable<ExportFormatMetadata[]> {
|
||||
const baseFormats: ExportFormatMetadata[] = [
|
||||
{ name: ".json", format: "json" },
|
||||
{ name: ".csv", format: "csv" },
|
||||
{ name: ".json (Encrypted)", format: "encrypted_json" },
|
||||
];
|
||||
|
||||
// ZIP format with attachments is only available for individual vault exports
|
||||
if (options.isMyVault) {
|
||||
return of([...baseFormats, { name: ".zip (with attachments)", format: "zip" }]);
|
||||
}
|
||||
|
||||
return of(baseFormats);
|
||||
}
|
||||
|
||||
/** Checks if the provided userId matches the currently authenticated user
|
||||
* @param userId The userId to check
|
||||
* @throws Error if the userId does not match the currently authenticated user
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "fileFormat" | i18n }}</bit-label>
|
||||
<bit-select formControlName="format">
|
||||
<bit-option *ngFor="let f of formatOptions" [value]="f.value" [label]="f.name" />
|
||||
<bit-option *ngFor="let f of formatOptions$ | async" [value]="f.format" [label]="f.name" />
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
@@ -67,7 +67,11 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { GeneratorServicesModule } from "@bitwarden/generator-components";
|
||||
import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core";
|
||||
import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core";
|
||||
import {
|
||||
ExportedVault,
|
||||
ExportFormatMetadata,
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { EncryptedExportType } from "../enums/encrypted-export-type.enum";
|
||||
|
||||
@@ -231,11 +235,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
fileEncryptionType: [EncryptedExportType.AccountEncrypted],
|
||||
});
|
||||
|
||||
formatOptions = [
|
||||
{ name: ".json", value: "json" },
|
||||
{ name: ".csv", value: "csv" },
|
||||
{ name: ".json (Encrypted)", value: "encrypted_json" },
|
||||
];
|
||||
/**
|
||||
* Observable stream of available export format options
|
||||
* Dynamically updates based on vault selection (My Vault vs Organization)
|
||||
*/
|
||||
formatOptions$: Observable<ExportFormatMetadata[]>;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private onlyManagedCollections = true;
|
||||
@@ -338,17 +342,28 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
private observeFormSelections(): void {
|
||||
this.exportForm.controls.vaultSelector.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((value) => {
|
||||
this.organizationId = value !== "myVault" ? value : undefined;
|
||||
// Set up dynamic format options based on vault selection
|
||||
this.formatOptions$ = this.exportForm.controls.vaultSelector.valueChanges.pipe(
|
||||
startWith(this.exportForm.controls.vaultSelector.value),
|
||||
map((vaultSelection) => {
|
||||
const isMyVault = vaultSelection === "myVault";
|
||||
// Update organizationId based on vault selection
|
||||
this.organizationId = isMyVault ? undefined : vaultSelection;
|
||||
return { isMyVault };
|
||||
}),
|
||||
switchMap((options) => this.exportService.formats$(options)),
|
||||
tap((formats) => {
|
||||
// Preserve the current format selection if it's still available in the new format list
|
||||
const currentFormat = this.exportForm.get("format").value;
|
||||
const isFormatAvailable = formats.some((f) => f.format === currentFormat);
|
||||
|
||||
this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip");
|
||||
this.exportForm.get("format").setValue("json");
|
||||
if (value === "myVault") {
|
||||
this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" });
|
||||
// Only reset to json if the current format is no longer available
|
||||
if (!isFormatAvailable) {
|
||||
this.exportForm.get("format").setValue("json");
|
||||
}
|
||||
});
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("DeleteAttachmentComponent", () => {
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["title"]).toBe("deleteAttachmentName");
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("deleteAttachmentName");
|
||||
});
|
||||
|
||||
it("does not delete when the user cancels the dialog", async () => {
|
||||
|
||||
@@ -149,13 +149,17 @@ describe("UriOptionComponent", () => {
|
||||
expect(getMatchDetectionSelect()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should update the match detection button title when the toggle is clicked", () => {
|
||||
it("should update the match detection button aria-label when the toggle is clicked", () => {
|
||||
component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact });
|
||||
fixture.detectChanges();
|
||||
expect(getToggleMatchDetectionBtn().title).toBe("showMatchDetection https://example.com");
|
||||
expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
|
||||
"showMatchDetection https://example.com",
|
||||
);
|
||||
getToggleMatchDetectionBtn().click();
|
||||
fixture.detectChanges();
|
||||
expect(getToggleMatchDetectionBtn().title).toBe("hideMatchDetection https://example.com");
|
||||
expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
|
||||
"hideMatchDetection https://example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => {
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["title"]).toBe("downloadAttachmentName");
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName");
|
||||
});
|
||||
|
||||
describe("download attachment", () => {
|
||||
|
||||
@@ -28,6 +28,8 @@ class MockCipherView {
|
||||
constructor(
|
||||
public id: string,
|
||||
private deleted: boolean,
|
||||
public edit: boolean = true,
|
||||
public viewPassword: boolean = true,
|
||||
) {}
|
||||
get isDeleted() {
|
||||
return this.deleted;
|
||||
@@ -65,33 +67,261 @@ describe("AtRiskPasswordCalloutService", () => {
|
||||
service = TestBed.inject(AtRiskPasswordCalloutService);
|
||||
});
|
||||
|
||||
describe("pendingTasks$", () => {
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
"returns tasks filtered by UpdateAtRiskCredential type with valid cipher permissions",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [
|
||||
new MockCipherView("c1", false, true, true),
|
||||
new MockCipherView("c2", false, true, true),
|
||||
],
|
||||
expectedLength: 2,
|
||||
expectedFirstId: "t1",
|
||||
},
|
||||
{
|
||||
description: "filters out tasks with wrong task type",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: 999 as SecurityTaskType,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [
|
||||
new MockCipherView("c1", false, true, true),
|
||||
new MockCipherView("c2", false, true, true),
|
||||
],
|
||||
expectedLength: 1,
|
||||
expectedFirstId: "t1",
|
||||
},
|
||||
{
|
||||
description: "filters out tasks with missing associated cipher",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c-nonexistent",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [new MockCipherView("c1", false, true, true)],
|
||||
expectedLength: 1,
|
||||
expectedFirstId: "t1",
|
||||
},
|
||||
{
|
||||
description: "filters out tasks when cipher edit permission is false",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [
|
||||
new MockCipherView("c1", false, true, true),
|
||||
new MockCipherView("c2", false, false, true),
|
||||
],
|
||||
expectedLength: 1,
|
||||
expectedFirstId: "t1",
|
||||
},
|
||||
{
|
||||
description: "filters out tasks when cipher viewPassword permission is false",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [
|
||||
new MockCipherView("c1", false, true, true),
|
||||
new MockCipherView("c2", false, true, false),
|
||||
],
|
||||
expectedLength: 1,
|
||||
expectedFirstId: "t1",
|
||||
},
|
||||
{
|
||||
description: "filters out tasks when cipher is deleted",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [
|
||||
new MockCipherView("c1", false, true, true),
|
||||
new MockCipherView("c2", true, true, true),
|
||||
],
|
||||
expectedLength: 1,
|
||||
expectedFirstId: "t1",
|
||||
},
|
||||
])("$description", async ({ tasks, ciphers, expectedLength, expectedFirstId }) => {
|
||||
jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks));
|
||||
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers));
|
||||
|
||||
const result = await firstValueFrom(service.pendingTasks$(userId));
|
||||
|
||||
expect(result).toHaveLength(expectedLength);
|
||||
if (expectedFirstId) {
|
||||
expect(result[0].id).toBe(expectedFirstId);
|
||||
}
|
||||
});
|
||||
|
||||
it("correctly filters mixed valid and invalid tasks", async () => {
|
||||
const tasks: SecurityTask[] = [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t3",
|
||||
cipherId: "c3",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t4",
|
||||
cipherId: "c4",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t5",
|
||||
cipherId: "c5",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
];
|
||||
const ciphers = [
|
||||
new MockCipherView("c1", false, true, true), // valid
|
||||
new MockCipherView("c2", false, false, true), // no edit
|
||||
new MockCipherView("c3", true, true, true), // deleted
|
||||
new MockCipherView("c4", false, true, false), // no viewPassword
|
||||
// c5 missing
|
||||
];
|
||||
|
||||
jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks));
|
||||
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers));
|
||||
|
||||
const result = await firstValueFrom(service.pendingTasks$(userId));
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("t1");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: "returns empty array when no tasks match filter criteria",
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as SecurityTask,
|
||||
],
|
||||
ciphers: [new MockCipherView("c1", true, true, true)], // deleted
|
||||
},
|
||||
{
|
||||
description: "returns empty array when no pending tasks exist",
|
||||
tasks: [],
|
||||
ciphers: [new MockCipherView("c1", false, true, true)],
|
||||
},
|
||||
])("$description", async ({ tasks, ciphers }) => {
|
||||
jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks));
|
||||
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers));
|
||||
|
||||
const result = await firstValueFrom(service.pendingTasks$(userId));
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completedTasks$", () => {
|
||||
it(" should return true if completed tasks exist", async () => {
|
||||
it("returns true if completed tasks exist", async () => {
|
||||
const tasks: SecurityTask[] = [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
} as any,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as any,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t3",
|
||||
cipherId: "nope",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
} as any,
|
||||
} as SecurityTask,
|
||||
{
|
||||
id: "t4",
|
||||
cipherId: "c3",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
} as any,
|
||||
} as SecurityTask,
|
||||
];
|
||||
|
||||
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks));
|
||||
@@ -110,7 +340,7 @@ describe("AtRiskPasswordCalloutService", () => {
|
||||
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
it("should return false if banner has been dismissed", async () => {
|
||||
it("returns false if banner has been dismissed", async () => {
|
||||
const state: AtRiskPasswordCalloutData = {
|
||||
hasInteractedWithTasks: true,
|
||||
tasksBannerDismissed: true,
|
||||
@@ -123,7 +353,7 @@ describe("AtRiskPasswordCalloutService", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => {
|
||||
it("returns true when has completed tasks, no pending tasks, and banner not dismissed", async () => {
|
||||
const completedTasks = [
|
||||
{
|
||||
id: "t1",
|
||||
|
||||
@@ -45,6 +45,8 @@ export class AtRiskPasswordCalloutService {
|
||||
return (
|
||||
t.type === SecurityTaskType.UpdateAtRiskCredential &&
|
||||
associatedCipher &&
|
||||
associatedCipher.edit &&
|
||||
associatedCipher.viewPassword &&
|
||||
!associatedCipher.isDeleted
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user