mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
Merge branch 'main' into SM-1743-featureflag-disablesm
This commit is contained in:
@@ -24,6 +24,8 @@ import { KeyService } from "@bitwarden/key-management";
|
||||
selector: "app-user-verification",
|
||||
standalone: false,
|
||||
})
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/PM-28232): Use Directive suffix
|
||||
// eslint-disable-next-line @angular-eslint/directive-class-suffix
|
||||
export class UserVerificationComponent implements ControlValueAccessor, OnInit, OnDestroy {
|
||||
private _invalidSecret = false;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
|
||||
21
libs/angular/src/auth/constants/auth-route.constant.ts
Normal file
21
libs/angular/src/auth/constants/auth-route.constant.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Constants for auth team owned full routes which are shared across clients.
|
||||
*/
|
||||
export const AuthRoute = Object.freeze({
|
||||
SignUp: "signup",
|
||||
FinishSignUp: "finish-signup",
|
||||
Login: "login",
|
||||
LoginWithDevice: "login-with-device",
|
||||
AdminApprovalRequested: "admin-approval-requested",
|
||||
PasswordHint: "hint",
|
||||
LoginInitiated: "login-initiated",
|
||||
SetInitialPassword: "set-initial-password",
|
||||
ChangePassword: "change-password",
|
||||
Sso: "sso",
|
||||
TwoFactor: "2fa",
|
||||
AuthenticationTimeout: "authentication-timeout",
|
||||
NewDeviceVerification: "device-verification",
|
||||
LoginWithPasskey: "login-with-passkey",
|
||||
} as const);
|
||||
|
||||
export type AuthRoute = (typeof AuthRoute)[keyof typeof AuthRoute];
|
||||
1
libs/angular/src/auth/constants/index.ts
Normal file
1
libs/angular/src/auth/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-route.constant";
|
||||
@@ -22,7 +22,7 @@
|
||||
<span slot="secondary" class="tw-text-sm">
|
||||
<br />
|
||||
<div>
|
||||
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span class="tw-font-medium"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</span>
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
<div>
|
||||
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
|
||||
<span class="tw-font-medium">{{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div bitTypography="body2">
|
||||
{{ "accessing" | i18n }}:
|
||||
<button [bitMenuTriggerFor]="environmentOptions" bitLink type="button">
|
||||
<b class="tw-text-primary-600 tw-font-semibold">{{
|
||||
<b class="tw-text-primary-600 tw-font-medium">{{
|
||||
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
|
||||
}}</b>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -19,6 +19,7 @@ import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ export type State = "assert" | "assertFailed";
|
||||
})
|
||||
export class LoginViaWebAuthnComponent implements OnInit {
|
||||
protected currentState: State = "assert";
|
||||
private shouldAutoClosePopout = false;
|
||||
|
||||
protected readonly Icons = {
|
||||
TwoFactorAuthSecurityKeyIcon,
|
||||
@@ -70,6 +72,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
|
||||
constructor(
|
||||
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private logService: LogService,
|
||||
private validationService: ValidationService,
|
||||
private i18nService: I18nService,
|
||||
@@ -77,9 +80,14 @@ export class LoginViaWebAuthnComponent implements OnInit {
|
||||
private keyService: KeyService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private messagingService: MessagingService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check if we should auto-close the popout after successful authentication
|
||||
this.shouldAutoClosePopout =
|
||||
this.route.snapshot.queryParamMap.get("autoClosePopout") === "true";
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.authenticate();
|
||||
@@ -120,7 +128,18 @@ export class LoginViaWebAuthnComponent implements OnInit {
|
||||
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
|
||||
if (userKey) {
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, null);
|
||||
}
|
||||
|
||||
// If autoClosePopout is enabled and we're in a browser extension,
|
||||
// re-open the regular popup and close this popout window
|
||||
if (
|
||||
this.shouldAutoClosePopout &&
|
||||
this.platformUtilsService.getClientType() === ClientType.Browser
|
||||
) {
|
||||
this.messagingService.send("openPopup");
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate([this.successRoute]);
|
||||
|
||||
@@ -182,7 +182,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
if (userKey == null) {
|
||||
masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey);
|
||||
} else {
|
||||
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
|
||||
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
|
||||
masterKey,
|
||||
userKey,
|
||||
);
|
||||
}
|
||||
|
||||
return masterKeyEncryptedUserKey;
|
||||
@@ -195,10 +198,13 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
userId: UserId,
|
||||
) {
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
);
|
||||
userDecryptionOpts.hasMasterPassword = true;
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
|
||||
userId,
|
||||
userDecryptionOpts,
|
||||
);
|
||||
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
|
||||
|
||||
@@ -149,7 +149,9 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
|
||||
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
|
||||
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
userDecryptionOptionsSubject,
|
||||
);
|
||||
|
||||
setPasswordRequest = new SetPasswordRequest(
|
||||
credentials.newServerMasterKeyHash,
|
||||
@@ -362,7 +364,8 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
|
||||
userId,
|
||||
userDecryptionOptions,
|
||||
);
|
||||
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
|
||||
@@ -560,7 +563,8 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
|
||||
userId,
|
||||
userDecryptionOptions,
|
||||
);
|
||||
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./premium.component";
|
||||
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";
|
||||
|
||||
@@ -10,8 +10,15 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
selector: "app-premium-badge",
|
||||
standalone: true,
|
||||
template: `
|
||||
<button type="button" *appNotPremium bitBadge variant="success" (click)="promptForPremium()">
|
||||
{{ "premium" | i18n }}
|
||||
<button
|
||||
type="button"
|
||||
*appNotPremium
|
||||
bitBadge
|
||||
[variant]="'primary'"
|
||||
class="!tw-text-primary-600 !tw-border-primary-600"
|
||||
(click)="promptForPremium($event)"
|
||||
>
|
||||
<i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
|
||||
</button>
|
||||
`,
|
||||
imports: [BadgeModule, JslibModule],
|
||||
@@ -21,7 +28,9 @@ export class PremiumBadgeComponent {
|
||||
|
||||
constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {}
|
||||
|
||||
async promptForPremium() {
|
||||
async promptForPremium(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { BadgeModule, I18nMockService } from "@bitwarden/components";
|
||||
|
||||
import { PremiumBadgeComponent } from "./premium-badge.component";
|
||||
|
||||
class MockMessagingService implements MessageSender {
|
||||
send = () => {
|
||||
alert("Clicked on badge");
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Badge",
|
||||
component: PremiumBadgeComponent,
|
||||
@@ -36,16 +29,10 @@ export default {
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
premium: "Premium",
|
||||
upgrade: "Upgrade",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MessageSender,
|
||||
useFactory: () => {
|
||||
return new MockMessagingService();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
@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">
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ "upgradeToPremium" | i18n }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tagline with consistent height (exactly 2 lines) -->
|
||||
<div class="tw-h-6">
|
||||
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
|
||||
{{ cardDetails.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
@if (cardDetails.price) {
|
||||
<div class="tw-mt-5">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-my-5 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
[block]="true"
|
||||
(click)="upgrade()"
|
||||
type="button"
|
||||
>
|
||||
@if (cardDetails.button.icon?.position === "before") {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ cardDetails.button.text }}
|
||||
@if (
|
||||
cardDetails.button.icon &&
|
||||
(cardDetails.button.icon.position === "after" || !cardDetails.button.icon.position)
|
||||
) {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-ms-2" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="tw-flex-grow">
|
||||
@if (cardDetails.features.length > 0) {
|
||||
<ul class="tw-list-none tw-p-0 tw-m-0">
|
||||
@for (feature of cardDetails.features; track feature) {
|
||||
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
|
||||
<i
|
||||
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
|
||||
feature
|
||||
}}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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,
|
||||
providedStorageGB: 1,
|
||||
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,
|
||||
providedStorageGB: 1,
|
||||
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", async () => {
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://vault.bitwarden.com/#/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}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("self-hosted environment", () => {
|
||||
it("should handle null price data for self-hosted environment", async () => {
|
||||
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Advanced features for power users",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: undefined as any, // self-host will have these prices empty
|
||||
annualPricePerAdditionalStorageGB: undefined as any,
|
||||
providedStorageGB: undefined as any,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature 1" },
|
||||
{ key: "feature2", value: "Feature 2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([selfHostedPremiumTier]),
|
||||
);
|
||||
|
||||
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
const selfHostedComponent = selfHostedFixture.componentInstance;
|
||||
selfHostedFixture.detectChanges();
|
||||
|
||||
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.price).toBeUndefined();
|
||||
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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,
|
||||
providedStorageGB: 1,
|
||||
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!" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Complete online security",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
{ key: "emergencyAccess", value: "Emergency access" },
|
||||
{ key: "breachMonitoring", value: "Breach monitoring" },
|
||||
{ key: "andMoreFeatures", value: "And more!" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Upgrade Dialog",
|
||||
component: PremiumUpgradeDialogComponent,
|
||||
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 = {};
|
||||
|
||||
export const NoPricingData: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: {
|
||||
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
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 { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@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<SubscriptionPricingCardDetails | 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$);
|
||||
const vaultUrl =
|
||||
environment.getWebVaultUrl() +
|
||||
"/#/settings/subscription/premium?callToAction=upgradeToPremium";
|
||||
this.platformUtilsService.launchUri(vaultUrl);
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private mapPremiumTierToCardDetails(
|
||||
tier: PersonalSubscriptionPricingTier,
|
||||
): SubscriptionPricingCardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: tier.passwordManager.annualPrice
|
||||
? {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
}
|
||||
: undefined,
|
||||
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, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -16,6 +17,7 @@ import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/com
|
||||
export class PremiumComponent implements OnInit {
|
||||
isPremium$: Observable<boolean>;
|
||||
price = 10;
|
||||
storageProvidedGb = 0;
|
||||
refreshPromise: Promise<any>;
|
||||
cloudWebVaultUrl: string;
|
||||
|
||||
@@ -29,6 +31,7 @@ export class PremiumComponent implements OnInit {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
) {
|
||||
this.isPremium$ = accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
@@ -39,6 +42,9 @@ export class PremiumComponent implements OnInit {
|
||||
|
||||
async ngOnInit() {
|
||||
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
|
||||
const premiumResponse = await this.billingApiService.getPremiumPlan();
|
||||
this.storageProvidedGb = premiumResponse.storage.provided;
|
||||
this.price = premiumResponse.seat.price;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit {
|
||||
private templateRef: TemplateRef<any>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
@@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const premium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
this.billingAccountProfileStateService
|
||||
.hasPremiumFromAnySource$(account.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((premium) => {
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PremiumInterestStateService } from "./premium-interest-state.service.abstraction";
|
||||
|
||||
@Injectable()
|
||||
export class NoopPremiumInterestStateService implements PremiumInterestStateService {
|
||||
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
|
||||
return null;
|
||||
} // no-op
|
||||
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {} // no-op
|
||||
async clearPremiumInterest(userId: UserId): Promise<void> {} // no-op
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* A service that manages state which conveys whether or not a user has expressed interest
|
||||
* in setting up a premium subscription. This applies for users who began the registration
|
||||
* process on https://bitwarden.com/go/start-premium/, which is a marketing page designed
|
||||
* to streamline users who intend to setup a premium subscription after registration.
|
||||
* - Implemented in Web only. No-op for other clients.
|
||||
*/
|
||||
export abstract class PremiumInterestStateService {
|
||||
abstract getPremiumInterest(userId: UserId): Promise<boolean | null>;
|
||||
abstract setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void>;
|
||||
abstract clearPremiumInterest(userId: UserId): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { ButtonType } from "@bitwarden/components";
|
||||
|
||||
export type SubscriptionPricingCardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price?: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
|
||||
features: string[];
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
<bit-callout [icon]="icon" [title]="title" [type]="$any(type)" [useAlertRole]="useAlertRole">
|
||||
<div class="tw-pl-7 tw-m-0" *ngIf="enforcedPolicyOptions">
|
||||
{{ enforcedPolicyMessage }}
|
||||
<ul>
|
||||
<li *ngIf="enforcedPolicyOptions?.minComplexity > 0">
|
||||
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.minLength > 0">
|
||||
{{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireUpper">
|
||||
{{ "policyInEffectUppercase" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireLower">
|
||||
{{ "policyInEffectLowercase" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireNumbers">
|
||||
{{ "policyInEffectNumbers" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireSpecial">
|
||||
{{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ng-content></ng-content>
|
||||
</bit-callout>
|
||||
@@ -1,56 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CalloutTypes } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* @deprecated use the CL's `CalloutComponent` instead
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-callout",
|
||||
templateUrl: "callout.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class DeprecatedCalloutComponent implements OnInit {
|
||||
@Input() type: CalloutTypes = "info";
|
||||
@Input() icon: string;
|
||||
@Input() title: string;
|
||||
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
@Input() enforcedPolicyMessage: string;
|
||||
@Input() useAlertRole = false;
|
||||
|
||||
calloutStyle: string;
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.calloutStyle = this.type;
|
||||
|
||||
if (this.enforcedPolicyMessage === undefined) {
|
||||
this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect");
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordScoreAlertDisplay() {
|
||||
if (this.enforcedPolicyOptions == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let str: string;
|
||||
switch (this.enforcedPolicyOptions.minComplexity) {
|
||||
case 4:
|
||||
str = this.i18nService.t("strong");
|
||||
break;
|
||||
case 3:
|
||||
str = this.i18nService.t("good");
|
||||
break;
|
||||
default:
|
||||
str = this.i18nService.t("weak");
|
||||
break;
|
||||
}
|
||||
return str + " (" + this.enforcedPolicyOptions.minComplexity + ")";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InjectFlags, InjectOptions, Injector, ProviderToken } from "@angular/core";
|
||||
import { InjectOptions, Injector, ProviderToken } from "@angular/core";
|
||||
|
||||
export class ModalInjector implements Injector {
|
||||
constructor(
|
||||
@@ -12,8 +12,8 @@ export class ModalInjector implements Injector {
|
||||
options: InjectOptions & { optional?: false },
|
||||
): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue: null, options: InjectOptions): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions | InjectFlags): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: InjectFlags): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions | null): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: null): T;
|
||||
get(token: any, notFoundValue?: any): any;
|
||||
get(token: any, notFoundValue?: any, flags?: any): any {
|
||||
return this._additionalTokens.get(token) ?? this._parentInjector.get<any>(token, notFoundValue);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -45,6 +45,8 @@ export function _cipherListVirtualScrollStrategyFactory(cipherListDir: CipherLis
|
||||
},
|
||||
],
|
||||
})
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/PM-28232): Use Directive suffix
|
||||
// eslint-disable-next-line @angular-eslint/directive-class-suffix
|
||||
export class CipherListVirtualScroll extends CdkFixedSizeVirtualScroll {
|
||||
_scrollStrategy: CipherListVirtualScrollStrategy;
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export class CopyTextDirective {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appCopyText") copyText: string;
|
||||
|
||||
@HostListener("copy") onCopy() {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core";
|
||||
standalone: false,
|
||||
})
|
||||
export class FallbackSrcDirective {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appFallbackSrc") appFallbackSrc: string;
|
||||
|
||||
/** Only try setting the fallback once. This prevents an infinite loop if the fallback itself is missing. */
|
||||
|
||||
@@ -13,6 +13,8 @@ const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag;
|
||||
const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag;
|
||||
const testStringFeatureValue = "test-value";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: `
|
||||
<div *appIfFeature="testBooleanFeature">
|
||||
|
||||
@@ -20,12 +20,16 @@ export class IfFeatureDirective implements OnInit {
|
||||
/**
|
||||
* The feature flag to check.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() appIfFeature: FeatureFlag;
|
||||
|
||||
/**
|
||||
* Optional value to compare against the value of the feature flag in the config service.
|
||||
* @default true
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() appIfFeatureValue: AllowedFeatureFlagTypes = true;
|
||||
|
||||
private hasView = false;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
|
||||
standalone: false,
|
||||
})
|
||||
export class InputVerbatimDirective implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() set appInputVerbatim(condition: boolean | string) {
|
||||
this.disableComplete = condition === "" || condition === true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
export class LaunchClickDirective {
|
||||
constructor(private platformUtilsService: PlatformUtilsService) {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appLaunchClick") uriToLaunch = "";
|
||||
|
||||
@HostListener("click") onClick() {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Directive, HostListener, Input } from "@angular/core";
|
||||
},
|
||||
})
|
||||
export class TextDragDirective {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({
|
||||
alias: "appTextDrag",
|
||||
required: true,
|
||||
|
||||
@@ -14,7 +14,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
standalone: false,
|
||||
})
|
||||
export class TrueFalseValueDirective implements ControlValueAccessor {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() trueValue: boolean | string = true;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() falseValue: boolean | string = false;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
|
||||
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
|
||||
import { NotPremiumDirective } from "./billing/directives/not-premium.directive";
|
||||
import { DeprecatedCalloutComponent } from "./components/callout.component";
|
||||
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
|
||||
import { ApiActionDirective } from "./directives/api-action.directive";
|
||||
import { BoxRowDirective } from "./directives/box-row.directive";
|
||||
@@ -86,7 +85,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
A11yInvalidDirective,
|
||||
ApiActionDirective,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
@@ -115,7 +113,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
AutofocusDirective,
|
||||
ToastModule,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export abstract class EncryptedMigrationsSchedulerService {
|
||||
/**
|
||||
* Runs migrations for a user if needed, handling both interactive and non-interactive cases
|
||||
* @param userId The user ID to run migrations for
|
||||
*/
|
||||
abstract runMigrationsIfNeeded(userId: UserId): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { FakeAccountService } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import {
|
||||
DefaultEncryptedMigrationsSchedulerService,
|
||||
ENCRYPTED_MIGRATION_DISMISSED,
|
||||
} from "./encrypted-migrations-scheduler.service";
|
||||
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
|
||||
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "SomeOtherUser" as UserId;
|
||||
const accounts: Record<UserId, AccountInfo> = {
|
||||
[SomeUser]: {
|
||||
name: "some user",
|
||||
email: "some.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
[AnotherUser]: {
|
||||
name: "some other user",
|
||||
email: "some.other.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("DefaultEncryptedMigrationsSchedulerService", () => {
|
||||
let service: DefaultEncryptedMigrationsSchedulerService;
|
||||
const mockAccountService = new FakeAccountService(accounts);
|
||||
const mockAuthService = mock<AuthService>();
|
||||
const mockEncryptedMigrator = mock<EncryptedMigrator>();
|
||||
const mockStateProvider = mock<StateProvider>();
|
||||
const mockSyncService = mock<SyncService>();
|
||||
const mockDialogService = mock<DialogService>();
|
||||
const mockToastService = mock<ToastService>();
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockRouter = mock<Router>();
|
||||
|
||||
const mockUserId = "test-user-id" as UserId;
|
||||
const mockMasterPassword = "test-master-password";
|
||||
|
||||
const createMockUserState = <T>(value: T): jest.Mocked<SingleUserState<T>> =>
|
||||
({
|
||||
state$: of(value),
|
||||
userId: mockUserId,
|
||||
update: jest.fn(),
|
||||
combinedState$: of([mockUserId, value]),
|
||||
}) as any;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockDialogRef = {
|
||||
closed: of(mockMasterPassword),
|
||||
};
|
||||
|
||||
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
|
||||
mockI18nService.t.mockReturnValue("translated_migrationsFailed");
|
||||
(mockRouter as any)["events"] = of({ url: "/vault" }) as any;
|
||||
|
||||
service = new DefaultEncryptedMigrationsSchedulerService(
|
||||
mockSyncService,
|
||||
mockAccountService,
|
||||
mockStateProvider,
|
||||
mockEncryptedMigrator,
|
||||
mockAuthService,
|
||||
mockLogService,
|
||||
mockDialogService,
|
||||
mockToastService,
|
||||
mockI18nService,
|
||||
mockRouter,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runMigrationsIfNeeded", () => {
|
||||
it("should return early if user is not unlocked", async () => {
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockEncryptedMigrator.needsMigrations).not.toHaveBeenCalled();
|
||||
expect(mockLogService.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log and return when no migration is needed", async () => {
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
mockEncryptedMigrator.needsMigrations.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[EncryptedMigrationsScheduler] No migrations needed for user ${mockUserId}`,
|
||||
);
|
||||
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should run migrations without interaction when master password is not required", async () => {
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
|
||||
);
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
|
||||
});
|
||||
|
||||
it("should run migrations with interaction when migration is needed", async () => {
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
const mockUserState = createMockUserState(null);
|
||||
mockStateProvider.getUser.mockReturnValue(mockUserState);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
|
||||
);
|
||||
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockMasterPassword,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMigrationsWithoutInteraction", () => {
|
||||
it("should run migrations without master password", async () => {
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors during migration without interaction", async () => {
|
||||
const mockError = new Error("Migration failed");
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
|
||||
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"[EncryptedMigrationsScheduler] Error during migration without interaction",
|
||||
mockError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMigrationsWithInteraction", () => {
|
||||
beforeEach(() => {
|
||||
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
});
|
||||
|
||||
it("should skip if migration was dismissed recently", async () => {
|
||||
const recentDismissDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
|
||||
const mockUserState = createMockUserState(recentDismissDate);
|
||||
mockStateProvider.getUser.mockReturnValue(mockUserState);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
ENCRYPTED_MIGRATION_DISMISSED,
|
||||
);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
|
||||
);
|
||||
expect(PromptMigrationPasswordComponent.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should prompt for migration if dismissed date is older than 24 hours", async () => {
|
||||
const oldDismissDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
|
||||
const mockUserState = createMockUserState(oldDismissDate);
|
||||
mockStateProvider.getUser.mockReturnValue(mockUserState);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
ENCRYPTED_MIGRATION_DISMISSED,
|
||||
);
|
||||
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockMasterPassword,
|
||||
);
|
||||
});
|
||||
|
||||
it("should prompt for migration if no dismiss date exists", async () => {
|
||||
const mockUserState = createMockUserState(null);
|
||||
mockStateProvider.getUser.mockReturnValue(mockUserState);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockMasterPassword,
|
||||
);
|
||||
});
|
||||
|
||||
it("should set dismiss date when empty password is provided", async () => {
|
||||
const mockUserState = createMockUserState(null);
|
||||
mockStateProvider.getUser.mockReturnValue(mockUserState);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(""), // Empty password
|
||||
};
|
||||
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
|
||||
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
|
||||
ENCRYPTED_MIGRATION_DISMISSED,
|
||||
expect.any(Date),
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle errors during migration prompt and show toast", async () => {
|
||||
const mockUserState = createMockUserState(null);
|
||||
mockStateProvider.getUser.mockReturnValue(mockUserState);
|
||||
|
||||
const mockError = new Error("Migration failed");
|
||||
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
|
||||
|
||||
await service.runMigrationsIfNeeded(mockUserId);
|
||||
|
||||
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
|
||||
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockMasterPassword,
|
||||
);
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"[EncryptedMigrationsScheduler] Error during migration prompt",
|
||||
mockError,
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "translated_migrationsFailed",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import {
|
||||
combineLatest,
|
||||
switchMap,
|
||||
of,
|
||||
firstValueFrom,
|
||||
filter,
|
||||
concatMap,
|
||||
Observable,
|
||||
map,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
UserKeyDefinition,
|
||||
ENCRYPTED_MIGRATION_DISK,
|
||||
StateProvider,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { EncryptedMigrationsSchedulerService } from "./encrypted-migrations-scheduler.service.abstraction";
|
||||
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
|
||||
|
||||
export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
|
||||
ENCRYPTED_MIGRATION_DISK,
|
||||
"encryptedMigrationDismissed",
|
||||
{
|
||||
deserializer: (obj: string) => (obj != null ? new Date(obj) : null),
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
const DISMISS_TIME_HOURS = 24;
|
||||
const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"];
|
||||
|
||||
/**
|
||||
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
|
||||
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
|
||||
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
|
||||
*/
|
||||
export class DefaultEncryptedMigrationsSchedulerService
|
||||
implements EncryptedMigrationsSchedulerService
|
||||
{
|
||||
isMigrating = false;
|
||||
url$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
private accountService: AccountService,
|
||||
private stateProvider: StateProvider,
|
||||
private encryptedMigrator: EncryptedMigrator,
|
||||
private authService: AuthService,
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private router: Router,
|
||||
) {
|
||||
this.url$ = this.router.events.pipe(
|
||||
filter((event: any) => event instanceof NavigationEnd),
|
||||
map((event: NavigationEnd) => event.url),
|
||||
);
|
||||
|
||||
// For all accounts, if the auth status changes to unlocked or a sync happens, prompt for migration
|
||||
this.accountService.accounts$
|
||||
.pipe(
|
||||
switchMap((accounts) => {
|
||||
const userIds = Object.keys(accounts) as UserId[];
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
return combineLatest(
|
||||
userIds.map((userId) =>
|
||||
combineLatest([
|
||||
this.authService.authStatusFor$(userId),
|
||||
this.syncService.lastSync$(userId).pipe(filter((lastSync) => lastSync != null)),
|
||||
this.url$,
|
||||
]).pipe(
|
||||
filter(
|
||||
([authStatus, _date, url]) =>
|
||||
authStatus === AuthenticationStatus.Unlocked && VAULT_ROUTES.includes(url),
|
||||
),
|
||||
concatMap(() => this.runMigrationsIfNeeded(userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async runMigrationsIfNeeded(userId: UserId): Promise<void> {
|
||||
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
|
||||
if (authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMigrating || this.encryptedMigrator.isRunningMigrations()) {
|
||||
this.logService.info(
|
||||
`[EncryptedMigrationsScheduler] Skipping migration check for user ${userId} because migrations are already in progress`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMigrating = true;
|
||||
switch (await this.encryptedMigrator.needsMigrations(userId)) {
|
||||
case "noMigrationNeeded":
|
||||
this.logService.info(
|
||||
`[EncryptedMigrationsScheduler] No migrations needed for user ${userId}`,
|
||||
);
|
||||
break;
|
||||
case "needsMigrationWithMasterPassword":
|
||||
this.logService.info(
|
||||
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
|
||||
);
|
||||
// If the user is unlocked, we can run migrations with the master password
|
||||
await this.runMigrationsWithInteraction(userId);
|
||||
break;
|
||||
case "needsMigration":
|
||||
this.logService.info(
|
||||
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
|
||||
);
|
||||
// If the user is unlocked, we can prompt for the master password
|
||||
await this.runMigrationsWithoutInteraction(userId);
|
||||
break;
|
||||
}
|
||||
this.isMigrating = false;
|
||||
}
|
||||
|
||||
private async runMigrationsWithoutInteraction(userId: UserId): Promise<void> {
|
||||
try {
|
||||
await this.encryptedMigrator.runMigrations(userId, null);
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
"[EncryptedMigrationsScheduler] Error during migration without interaction",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runMigrationsWithInteraction(userId: UserId): Promise<void> {
|
||||
// A dialog can be dismissed for a certain amount of time
|
||||
const dismissedDate = await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, ENCRYPTED_MIGRATION_DISMISSED).state$,
|
||||
);
|
||||
if (dismissedDate != null) {
|
||||
const now = new Date();
|
||||
const timeDiff = now.getTime() - (dismissedDate as Date).getTime();
|
||||
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff < DISMISS_TIME_HOURS) {
|
||||
this.logService.info(
|
||||
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const dialog = PromptMigrationPasswordComponent.open(this.dialogService);
|
||||
const masterPassword = await firstValueFrom(dialog.closed);
|
||||
if (Utils.isNullOrWhitespace(masterPassword)) {
|
||||
await this.stateProvider.setUserState(ENCRYPTED_MIGRATION_DISMISSED, new Date(), userId);
|
||||
} else {
|
||||
await this.encryptedMigrator.runMigrations(
|
||||
userId,
|
||||
masterPassword === undefined ? null : masterPassword,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("[EncryptedMigrationsScheduler] Error during migration prompt", error);
|
||||
// If migrations failed when the user actively was prompted, show a toast
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("migrationsFailed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<form [bitSubmit]="submit" [formGroup]="migrationPasswordForm">
|
||||
<bit-dialog>
|
||||
<div class="tw-font-semibold" bitDialogTitle>
|
||||
{{ "updateEncryptionSettingsTitle" | i18n }}
|
||||
</div>
|
||||
<div bitDialogContent>
|
||||
<p>
|
||||
{{ "updateEncryptionSettingsDesc" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/kdf-algorithms/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="external link"
|
||||
>
|
||||
{{ "learnMore" | i18n }}
|
||||
<i class="bwi bwi-external-link" aria-hidden="true"></i>
|
||||
</a>
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||
<bit-hint>{{ "confirmIdentityToContinue" | i18n }}</bit-hint>
|
||||
<input
|
||||
class="tw-font-mono"
|
||||
bitInput
|
||||
type="password"
|
||||
formControlName="masterPassword"
|
||||
[attr.title]="'masterPass' | i18n"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[attr.title]="'toggleVisibility' | i18n"
|
||||
[attr.aria-label]="'toggleVisibility' | i18n"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="submit"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
[disabled]="migrationPasswordForm.invalid"
|
||||
>
|
||||
<span>{{ "updateSettings" | i18n }}</span>
|
||||
</button>
|
||||
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
|
||||
{{ "later" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { filter, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import {
|
||||
LinkModule,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* This is a generic prompt to run encryption migrations that require the master password.
|
||||
*/
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "prompt-migration-password.component.html",
|
||||
imports: [
|
||||
DialogModule,
|
||||
LinkModule,
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
FormFieldModule,
|
||||
],
|
||||
})
|
||||
export class PromptMigrationPasswordComponent {
|
||||
private dialogRef = inject(DialogRef<string>);
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private uvService = inject(UserVerificationService);
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
migrationPasswordForm = this.formBuilder.group({
|
||||
masterPassword: ["", [Validators.required]],
|
||||
});
|
||||
|
||||
static open(dialogService: DialogService) {
|
||||
return dialogService.open<string>(PromptMigrationPasswordComponent);
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
const masterPasswordControl = this.migrationPasswordForm.controls.masterPassword;
|
||||
|
||||
if (!masterPasswordControl.value || masterPasswordControl.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId, email } = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
filter((account) => account != null),
|
||||
map((account) => {
|
||||
return {
|
||||
userId: account!.id,
|
||||
email: account!.email,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(await this.uvService.verifyUserByMasterPassword(
|
||||
{ type: VerificationType.MasterPassword, secret: masterPasswordControl.value },
|
||||
userId,
|
||||
email,
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Return the master password to the caller
|
||||
this.dialogRef.close(masterPasswordControl.value);
|
||||
};
|
||||
}
|
||||
@@ -35,9 +35,6 @@ export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SE
|
||||
export const LOGOUT_CALLBACK = new SafeInjectionToken<
|
||||
(logoutReason: LogoutReason, userId?: string) => Promise<void>
|
||||
>("LOGOUT_CALLBACK");
|
||||
export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise<void>>(
|
||||
"LOCKED_CALLBACK",
|
||||
);
|
||||
export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken<boolean>("SUPPORTS_SECURE_STORAGE");
|
||||
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
|
||||
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
|
||||
import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
@@ -40,9 +41,11 @@ import {
|
||||
AuthRequestService,
|
||||
AuthRequestServiceAbstraction,
|
||||
DefaultAuthRequestApiService,
|
||||
DefaultLockService,
|
||||
DefaultLoginSuccessHandlerService,
|
||||
DefaultLogoutService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LockService,
|
||||
LoginEmailService,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginStrategyService,
|
||||
@@ -100,7 +103,6 @@ import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from
|
||||
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction";
|
||||
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
|
||||
@@ -123,13 +125,17 @@ import { OrganizationInviteService } from "@bitwarden/common/auth/services/organ
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
|
||||
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/services/token.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
|
||||
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
|
||||
import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service";
|
||||
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
|
||||
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
|
||||
import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
|
||||
import {
|
||||
TwoFactorApiService,
|
||||
DefaultTwoFactorApiService,
|
||||
TwoFactorService,
|
||||
DefaultTwoFactorService,
|
||||
} from "@bitwarden/common/auth/two-factor";
|
||||
import {
|
||||
AutofillSettingsService,
|
||||
AutofillSettingsServiceAbstraction,
|
||||
@@ -151,6 +157,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
@@ -158,7 +165,9 @@ 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 { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import {
|
||||
DefaultKeyGenerationService,
|
||||
KeyGenerationService,
|
||||
@@ -169,14 +178,20 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
|
||||
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
|
||||
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
|
||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||
import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { DefaultKeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/services/default-key-connector-api.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
|
||||
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
|
||||
import { DefaultRotateableKeySetService } from "@bitwarden/common/key-management/keys/services/default-rotateable-key-set.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
@@ -194,6 +209,7 @@ import {
|
||||
SendPasswordService,
|
||||
DefaultSendPasswordService,
|
||||
} from "@bitwarden/common/key-management/sends";
|
||||
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
|
||||
import {
|
||||
DefaultVaultTimeoutService,
|
||||
DefaultVaultTimeoutSettingsService,
|
||||
@@ -213,13 +229,16 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import { UnsupportedActionsService } from "@bitwarden/common/platform/actions/unsupported-actions.service";
|
||||
import { IpcSessionRepository } from "@bitwarden/common/platform/ipc";
|
||||
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
||||
@@ -249,6 +268,7 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
|
||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
|
||||
import { DefaultRegisterSdkService } from "@bitwarden/common/platform/services/sdk/register-sdk.service";
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
||||
@@ -280,6 +300,7 @@ import {
|
||||
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -301,6 +322,7 @@ import {
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
import { DefaultCipherRiskService } from "@bitwarden/common/vault/services/default-cipher-risk.service";
|
||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||
@@ -312,6 +334,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
DefaultAnonLayoutWrapperDataService,
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
@@ -378,6 +401,10 @@ import { DefaultSetInitialPasswordService } from "../auth/password-management/se
|
||||
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
|
||||
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
|
||||
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
import { DocumentLangSetter } from "../platform/i18n";
|
||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||
@@ -397,7 +424,6 @@ import {
|
||||
HTTP_OPERATIONS,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
LOCALES_DIRECTORY,
|
||||
LOCKED_CALLBACK,
|
||||
LOG_MAC_FAILURES,
|
||||
LOGOUT_CALLBACK,
|
||||
OBSERVABLE_DISK_STORAGE,
|
||||
@@ -453,10 +479,6 @@ const safeProviders: SafeProvider[] = [
|
||||
},
|
||||
deps: [MessagingServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LOCKED_CALLBACK,
|
||||
useValue: null,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LOG_MAC_FAILURES,
|
||||
useValue: true,
|
||||
@@ -503,6 +525,23 @@ const safeProviders: SafeProvider[] = [
|
||||
TokenServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ChangeKdfService,
|
||||
useClass: DefaultChangeKdfService,
|
||||
deps: [ChangeKdfApiService, SdkService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EncryptedMigrator,
|
||||
useClass: DefaultEncryptedMigrator,
|
||||
deps: [
|
||||
KdfConfigService,
|
||||
ChangeKdfService,
|
||||
LogService,
|
||||
ConfigService,
|
||||
MasterPasswordServiceAbstraction,
|
||||
SyncService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginStrategyServiceAbstraction,
|
||||
useClass: LoginStrategyService,
|
||||
@@ -519,7 +558,7 @@ const safeProviders: SafeProvider[] = [
|
||||
KeyConnectorServiceAbstraction,
|
||||
EnvironmentService,
|
||||
StateServiceAbstraction,
|
||||
TwoFactorServiceAbstraction,
|
||||
TwoFactorService,
|
||||
I18nServiceAbstraction,
|
||||
EncryptService,
|
||||
PasswordStrengthServiceAbstraction,
|
||||
@@ -601,6 +640,11 @@ const safeProviders: SafeProvider[] = [
|
||||
MessagingServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherRiskService,
|
||||
useClass: DefaultCipherRiskService,
|
||||
deps: [SdkService, CipherServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: InternalFolderService,
|
||||
useClass: FolderService,
|
||||
@@ -668,7 +712,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
useClass: UserDecryptionOptionsService,
|
||||
deps: [StateProvider],
|
||||
deps: [SingleUserStateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
@@ -871,6 +915,7 @@ const safeProviders: SafeProvider[] = [
|
||||
StateProvider,
|
||||
LogService,
|
||||
DEFAULT_VAULT_TIMEOUT,
|
||||
SessionTimeoutTypeService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -878,22 +923,12 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultVaultTimeoutService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
CipherServiceAbstraction,
|
||||
FolderServiceAbstraction,
|
||||
CollectionService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
SearchServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
TokenServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
VaultTimeoutSettingsService,
|
||||
StateEventRunnerService,
|
||||
TaskSchedulerService,
|
||||
LogService,
|
||||
BiometricsService,
|
||||
LOCKED_CALLBACK,
|
||||
LockService,
|
||||
LogoutService,
|
||||
],
|
||||
}),
|
||||
@@ -917,7 +952,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
FolderServiceAbstraction,
|
||||
CipherServiceAbstraction,
|
||||
PinServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
@@ -937,7 +972,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
CipherServiceAbstraction,
|
||||
VaultExportApiService,
|
||||
PinServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
@@ -968,7 +1003,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: SignalRConnectionService,
|
||||
useClass: SignalRConnectionService,
|
||||
deps: [ApiServiceAbstraction, LogService],
|
||||
deps: [ApiServiceAbstraction, LogService, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebPushConnectionService,
|
||||
@@ -1018,6 +1053,7 @@ const safeProviders: SafeProvider[] = [
|
||||
WebPushConnectionService,
|
||||
AuthRequestAnsweringServiceAbstraction,
|
||||
ConfigService,
|
||||
InternalPolicyService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1056,7 +1092,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: InternalPolicyService,
|
||||
useClass: DefaultPolicyService,
|
||||
deps: [StateProvider, OrganizationServiceAbstraction],
|
||||
deps: [StateProvider, OrganizationServiceAbstraction, AccountServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PolicyServiceAbstraction,
|
||||
@@ -1085,7 +1121,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: MasterPasswordUnlockService,
|
||||
useClass: DefaultMasterPasswordUnlockService,
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService],
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorServiceAbstraction,
|
||||
@@ -1162,9 +1198,14 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorServiceAbstraction,
|
||||
useClass: TwoFactorService,
|
||||
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction, GlobalStateProvider],
|
||||
provide: TwoFactorService,
|
||||
useClass: DefaultTwoFactorService,
|
||||
deps: [
|
||||
I18nServiceAbstraction,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
GlobalStateProvider,
|
||||
TwoFactorApiService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: FormValidationErrorsServiceAbstraction,
|
||||
@@ -1223,7 +1264,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: AnonymousHubServiceAbstraction,
|
||||
useClass: AnonymousHubService,
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction],
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ValidationServiceAbstraction,
|
||||
@@ -1281,6 +1322,7 @@ const safeProviders: SafeProvider[] = [
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
LogService,
|
||||
ConfigService,
|
||||
AccountServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1315,16 +1357,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: PinServiceAbstraction,
|
||||
useClass: PinService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
EncryptService,
|
||||
KdfConfigService,
|
||||
KeyGenerationService,
|
||||
LogService,
|
||||
KeyService,
|
||||
SdkService,
|
||||
PinStateServiceAbstraction,
|
||||
],
|
||||
deps: [EncryptService, LogService, KeyService, SdkService, PinStateServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
@@ -1448,13 +1481,24 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService],
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useClass: DefaultSubscriptionPricingService,
|
||||
deps: [
|
||||
BillingApiServiceAbstraction,
|
||||
ConfigService,
|
||||
I18nServiceAbstraction,
|
||||
LogService,
|
||||
EnvironmentService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useClass: DefaultOrganizationManagementPreferencesService,
|
||||
@@ -1569,6 +1613,19 @@ const safeProviders: SafeProvider[] = [
|
||||
SsoLoginServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RegisterSdkService,
|
||||
useClass: DefaultRegisterSdkService,
|
||||
deps: [
|
||||
SdkClientFactory,
|
||||
EnvironmentService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
AccountServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
StateProvider,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SdkService,
|
||||
useClass: DefaultSdkService,
|
||||
@@ -1632,6 +1689,7 @@ const safeProviders: SafeProvider[] = [
|
||||
SsoLoginServiceAbstraction,
|
||||
SyncService,
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
EncryptedMigrator,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
@@ -1702,6 +1760,49 @@ const safeProviders: SafeProvider[] = [
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EncryptedMigrationsSchedulerService,
|
||||
useClass: DefaultEncryptedMigrationsSchedulerService,
|
||||
deps: [
|
||||
SyncService,
|
||||
AccountService,
|
||||
StateProvider,
|
||||
EncryptedMigrator,
|
||||
AuthServiceAbstraction,
|
||||
LogService,
|
||||
DialogService,
|
||||
ToastService,
|
||||
I18nServiceAbstraction,
|
||||
Router,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: APP_INITIALIZER as SafeInjectionToken<() => Promise<void>>,
|
||||
useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {},
|
||||
deps: [EncryptedMigrationsSchedulerService],
|
||||
multi: true,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LockService,
|
||||
useClass: DefaultLockService,
|
||||
deps: [
|
||||
AccountService,
|
||||
BiometricsService,
|
||||
VaultTimeoutSettingsService,
|
||||
LogoutService,
|
||||
MessagingServiceAbstraction,
|
||||
SearchServiceAbstraction,
|
||||
FolderServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
StateEventRunnerService,
|
||||
CipherServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
SystemService,
|
||||
ProcessReloadServiceAbstraction,
|
||||
LogService,
|
||||
KeyService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherArchiveService,
|
||||
useClass: DefaultCipherArchiveService,
|
||||
@@ -1712,11 +1813,31 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RotateableKeySetService,
|
||||
useClass: DefaultRotateableKeySetService,
|
||||
deps: [KeyService, EncryptService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NewDeviceVerificationComponentService,
|
||||
useClass: DefaultNewDeviceVerificationComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: IpcSessionRepository,
|
||||
useClass: IpcSessionRepository,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorApiService,
|
||||
useClass: DefaultKeyConnectorApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PremiumInterestStateService,
|
||||
useClass: NoopPremiumInterestStateService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
@@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
// Value = hours
|
||||
@@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
protected premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||
@@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => {
|
||||
this.type = val;
|
||||
this.typeChanged();
|
||||
});
|
||||
this.formGroup.controls.type.valueChanges
|
||||
.pipe(
|
||||
tap((val) => {
|
||||
this.type = val;
|
||||
}),
|
||||
switchMap(() => this.typeChanged()),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.formGroup.controls.selectedDeletionDatePreset.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
|
||||
typeChanged() {
|
||||
async typeChanged() {
|
||||
if (this.type === SendType.File && !this.alertShown) {
|
||||
if (!this.canAccessPremium) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("premiumRequired");
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else if (!this.emailVerified) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("emailVerificationRequired");
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
|
||||
<div
|
||||
class="tw-flex tw-justify-center tw-items-center"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
@if (data.imageEnabled && data.image) {
|
||||
<img
|
||||
@@ -16,7 +12,7 @@
|
||||
'tw-invisible tw-absolute': !imageLoaded(),
|
||||
'tw-size-6': !coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
[ngStyle]="iconStyle()"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
@@ -28,7 +24,7 @@
|
||||
'tw-bg-illustration-bg-primary tw-rounded-full':
|
||||
data.icon?.startsWith('bwi-') && coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
[ngStyle]="iconStyle()"
|
||||
>
|
||||
<i
|
||||
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
@@ -36,6 +32,7 @@
|
||||
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
|
||||
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
|
||||
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
|
||||
fontSize: size() ? size() + 'px' : null,
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
combineLatest,
|
||||
@@ -32,8 +32,32 @@ export class IconComponent {
|
||||
*/
|
||||
readonly coloredIcon = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Optional custom size for the icon in pixels.
|
||||
* When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
|
||||
* If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
|
||||
* This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
|
||||
* Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
|
||||
*/
|
||||
readonly size = input<number>();
|
||||
|
||||
readonly imageLoaded = signal(false);
|
||||
|
||||
/**
|
||||
* Computed style object for icon dimensions.
|
||||
* Centralizes the sizing logic to avoid repetition in the template.
|
||||
*/
|
||||
protected readonly iconStyle = computed(() => {
|
||||
if (this.coloredIcon()) {
|
||||
return { width: "36px", height: "36px" };
|
||||
}
|
||||
const size = this.size();
|
||||
if (size) {
|
||||
return { width: size + "px", height: size + "px" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
protected data$: Observable<CipherIconDetails>;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
>
|
||||
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
||||
<div>
|
||||
<h2 bitTypography="h4" class="tw-font-semibold !tw-mb-1">{{ title }}</h2>
|
||||
<h2 *ngIf="title()" bitTypography="h4" class="tw-font-medium !tw-mb-1">{{ title() }}</h2>
|
||||
<p
|
||||
*ngIf="subtitle"
|
||||
*ngIf="subtitle()"
|
||||
class="tw-text-main tw-mb-0"
|
||||
bitTypography="body2"
|
||||
[innerHTML]="subtitle"
|
||||
[innerHTML]="subtitle()"
|
||||
></p>
|
||||
<ng-content *ngIf="!subtitle"></ng-content>
|
||||
<ng-content *ngIf="!subtitle()"></ng-content>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
size="small"
|
||||
*ngIf="!persistent"
|
||||
*ngIf="!persistent()"
|
||||
(click)="handleDismiss()"
|
||||
class="-tw-me-2"
|
||||
[label]="'close' | i18n"
|
||||
@@ -28,10 +28,10 @@
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="primary"
|
||||
*ngIf="buttonText"
|
||||
*ngIf="buttonText()"
|
||||
(click)="handleButtonClick($event)"
|
||||
>
|
||||
{{ buttonText }}
|
||||
<i *ngIf="buttonIcon" [ngClass]="buttonIcon" class="bwi tw-ml-1" aria-hidden="true"></i>
|
||||
{{ buttonText() }}
|
||||
<i *ngIf="buttonIcon()" [ngClass]="buttonIcon()" class="bwi tw-ml-1" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { SpotlightComponent } from "./spotlight.component";
|
||||
|
||||
describe("SpotlightComponent", () => {
|
||||
let fixture: ComponentFixture<SpotlightComponent>;
|
||||
let component: SpotlightComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SpotlightComponent],
|
||||
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SpotlightComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
function detect(): void {
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("rendering when inputs are null", () => {
|
||||
it("should render without crashing when inputs are null/undefined", () => {
|
||||
// Explicitly drive the inputs to null to exercise template null branches
|
||||
fixture.componentRef.setInput("title", null);
|
||||
fixture.componentRef.setInput("subtitle", null);
|
||||
fixture.componentRef.setInput("buttonText", null);
|
||||
fixture.componentRef.setInput("buttonIcon", null);
|
||||
// persistent has a default, but drive it as well for coverage sanity
|
||||
fixture.componentRef.setInput("persistent", false);
|
||||
|
||||
expect(() => detect()).not.toThrow();
|
||||
|
||||
const root = fixture.debugElement.nativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close button visibility based on persistent", () => {
|
||||
it("should show the close button when persistent is false", () => {
|
||||
fixture.componentRef.setInput("persistent", false);
|
||||
detect();
|
||||
|
||||
// Assumes dismiss uses bitIconButton
|
||||
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
|
||||
|
||||
expect(dismissButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should hide the close button when persistent is true", () => {
|
||||
fixture.componentRef.setInput("persistent", true);
|
||||
detect();
|
||||
|
||||
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
|
||||
expect(dismissButton).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event emission", () => {
|
||||
it("should emit onButtonClick when CTA button is clicked", () => {
|
||||
const clickSpy = jest.fn();
|
||||
component.onButtonClick.subscribe(clickSpy);
|
||||
|
||||
fixture.componentRef.setInput("buttonText", "Click me");
|
||||
detect();
|
||||
|
||||
const buttonDe = fixture.debugElement.query(By.css("button[bitButton]"));
|
||||
expect(buttonDe).toBeTruthy();
|
||||
|
||||
const event = new MouseEvent("click");
|
||||
buttonDe.triggerEventHandler("click", event);
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
|
||||
});
|
||||
|
||||
it("should emit onDismiss when close button is clicked", () => {
|
||||
const dismissSpy = jest.fn();
|
||||
component.onDismiss.subscribe(dismissSpy);
|
||||
|
||||
fixture.componentRef.setInput("persistent", false);
|
||||
detect();
|
||||
|
||||
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
|
||||
expect(dismissButton).toBeTruthy();
|
||||
|
||||
dismissButton.triggerEventHandler("click", new MouseEvent("click"));
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handleButtonClick should emit via onButtonClick()", () => {
|
||||
const clickSpy = jest.fn();
|
||||
component.onButtonClick.subscribe(clickSpy);
|
||||
|
||||
const event = new MouseEvent("click");
|
||||
component.handleButtonClick(event);
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy.mock.calls[0][0]).toBe(event);
|
||||
});
|
||||
|
||||
it("handleDismiss should emit via onDismiss()", () => {
|
||||
const dismissSpy = jest.fn();
|
||||
component.onDismiss.subscribe(dismissSpy);
|
||||
|
||||
component.handleDismiss();
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content projection behavior", () => {
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [SpotlightComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<bit-spotlight>
|
||||
<span class="tw-text-sm">Projected content</span>
|
||||
</bit-spotlight>
|
||||
`,
|
||||
})
|
||||
class HostWithProjectionComponent {}
|
||||
|
||||
let hostFixture: ComponentFixture<HostWithProjectionComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
hostFixture = TestBed.createComponent(HostWithProjectionComponent);
|
||||
});
|
||||
|
||||
it("should render projected content inside the spotlight", () => {
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const projected = hostFixture.debugElement.query(By.css(".tw-text-sm"));
|
||||
expect(projected).toBeTruthy();
|
||||
expect(projected.nativeElement.textContent.trim()).toBe("Projected content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boolean attribute transform for persistent", () => {
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, SpotlightComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<!-- bare persistent attribute -->
|
||||
<bit-spotlight *ngIf="mode === 'bare'" persistent></bit-spotlight>
|
||||
|
||||
<!-- no persistent attribute -->
|
||||
<bit-spotlight *ngIf="mode === 'none'"></bit-spotlight>
|
||||
|
||||
<!-- explicit persistent="false" -->
|
||||
<bit-spotlight *ngIf="mode === 'falseStr'" persistent="false"></bit-spotlight>
|
||||
`,
|
||||
})
|
||||
class BooleanHostComponent {
|
||||
mode: "bare" | "none" | "falseStr" = "bare";
|
||||
}
|
||||
|
||||
let boolFixture: ComponentFixture<BooleanHostComponent>;
|
||||
let boolHost: BooleanHostComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
boolFixture = TestBed.createComponent(BooleanHostComponent);
|
||||
boolHost = boolFixture.componentInstance;
|
||||
});
|
||||
|
||||
function getSpotlight(): SpotlightComponent {
|
||||
const de = boolFixture.debugElement.query(By.directive(SpotlightComponent));
|
||||
return de.componentInstance as SpotlightComponent;
|
||||
}
|
||||
|
||||
it("treats bare 'persistent' attribute as true via booleanAttribute", () => {
|
||||
boolHost.mode = "bare";
|
||||
boolFixture.detectChanges();
|
||||
|
||||
const spotlight = getSpotlight();
|
||||
expect(spotlight.persistent()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses default false when 'persistent' is omitted", () => {
|
||||
boolHost.mode = "none";
|
||||
boolFixture.detectChanges();
|
||||
|
||||
const spotlight = getSpotlight();
|
||||
expect(spotlight.persistent()).toBe(false);
|
||||
});
|
||||
|
||||
it('treats persistent="false" as false', () => {
|
||||
boolHost.mode = "falseStr";
|
||||
boolFixture.detectChanges();
|
||||
|
||||
const spotlight = getSpotlight();
|
||||
expect(spotlight.persistent()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,28 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { booleanAttribute, ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||
|
||||
import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
// 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-spotlight",
|
||||
templateUrl: "spotlight.component.html",
|
||||
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SpotlightComponent {
|
||||
// The title of the component
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) title: string | null = null;
|
||||
readonly title = input<string>();
|
||||
// The subtitle of the component
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() subtitle?: string | null = null;
|
||||
readonly subtitle = input<string>();
|
||||
// The text to display on the button
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() buttonText?: string;
|
||||
// Wheter the component can be dismissed, if true, the component will not show a close button
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() persistent = false;
|
||||
readonly buttonText = input<string>();
|
||||
// Whether the component can be dismissed, if true, the component will not show a close button
|
||||
readonly persistent = input(false, { transform: booleanAttribute });
|
||||
// Optional icon to display on the button
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() buttonIcon: string | null = null;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() onDismiss = new EventEmitter<void>();
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() onButtonClick = new EventEmitter();
|
||||
readonly buttonIcon = input<string>();
|
||||
readonly onDismiss = output<void>();
|
||||
readonly onButtonClick = output<MouseEvent>();
|
||||
|
||||
handleButtonClick(event: MouseEvent): void {
|
||||
this.onButtonClick.emit(event);
|
||||
|
||||
@@ -37,6 +37,7 @@ export const NudgeType = {
|
||||
NewNoteItemStatus: "new-note-item-status",
|
||||
NewSshItemStatus: "new-ssh-item-status",
|
||||
GeneratorNudgeStatus: "generator-nudge-status",
|
||||
PremiumUpgrade: "premium-upgrade",
|
||||
} as const;
|
||||
|
||||
export type NudgeType = UnionOfValues<typeof NudgeType>;
|
||||
|
||||
@@ -88,14 +88,10 @@ export class VaultFilterComponent implements OnInit {
|
||||
this.folders$ = await this.vaultFilterService.buildNestedFolders();
|
||||
this.collections = await this.initCollections();
|
||||
|
||||
const userCanArchive = await firstValueFrom(
|
||||
this.cipherArchiveService.userCanArchive$(this.activeUserId),
|
||||
);
|
||||
const showArchiveVault = await firstValueFrom(
|
||||
this.cipherArchiveService.showArchiveVault$(this.activeUserId),
|
||||
this.showArchiveVaultFilter = await firstValueFrom(
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$,
|
||||
);
|
||||
|
||||
this.showArchiveVaultFilter = userCanArchive || showArchiveVault;
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user