1
0
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:
Brandon
2025-11-03 11:39:18 -05:00
539 changed files with 21463 additions and 4655 deletions

View 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];

View File

@@ -0,0 +1 @@
export * from "./auth-route.constant";

View File

@@ -1 +1,2 @@
export * from "./premium.component";
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";

View File

@@ -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());
}
}

View File

@@ -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: {

View File

@@ -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>
}

View File

@@ -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}`),
});
});
});
});

View File

@@ -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 = {};

View File

@@ -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);
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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. */

View File

@@ -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">

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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");

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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[]>;
}

View File

@@ -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.
*/

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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, {

View File

@@ -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;
}),

View File

@@ -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

View 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"),
}),
};
}

View 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;
};

View File

@@ -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,

View File

@@ -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", {

View 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.`);
}
}
}

View File

@@ -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();
});
});

View File

@@ -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");

View File

@@ -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[];
}
}

View 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 };

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
});
});
});

View 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,
);
}
}

View File

@@ -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",
]
: [],
)

View File

@@ -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>`,

View File

@@ -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>`,

View File

@@ -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);
}
});
}
}

View File

@@ -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";

View File

@@ -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"

View File

@@ -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]`

View File

@@ -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()"
>

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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");

View File

@@ -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 },
);
}
}

View File

@@ -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} />

View File

@@ -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(),
};

View File

@@ -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>
`,
}),
};

View File

@@ -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[] = [];
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
});
});
});

View File

@@ -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]);

View File

@@ -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", {

View File

@@ -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[]>;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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 }),
);
}
/**

View File

@@ -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 }}

View File

@@ -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),
});
}
}
}

View 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 () => {

View File

@@ -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",
);
});
});

View File

@@ -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", () => {

View File

@@ -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",

View File

@@ -45,6 +45,8 @@ export class AtRiskPasswordCalloutService {
return (
t.type === SecurityTaskType.UpdateAtRiskCredential &&
associatedCipher &&
associatedCipher.edit &&
associatedCipher.viewPassword &&
!associatedCipher.isDeleted
);
});