1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 20:04:02 +00:00

Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval

This commit is contained in:
rr-bw
2025-11-04 11:29:52 -08:00
219 changed files with 4998 additions and 1567 deletions

View File

@@ -0,0 +1,70 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
/**
* This guard is intended to prevent members of an organization from accessing
* routes based on compliance with organization
* policies. e.g Emergency access, which is a non-organization
* feature is restricted by the Auto Confirm policy.
*/
export function organizationPolicyGuard(
featureCallback: (
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) => Observable<boolean>,
): CanActivateFn {
return async () => {
const router = inject(Router);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const accountService = inject(AccountService);
const policyService = inject(PolicyService);
const configService = inject(ConfigService);
const syncService = inject(SyncService);
const synced = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => syncService.lastSync$(userId)),
),
);
if (synced == null) {
await syncService.fullSync(false);
}
const compliant = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => featureCallback(userId, configService, policyService)),
tap((compliant) => {
if (typeof compliant !== "boolean") {
throw new Error("Feature callback must return a boolean.");
}
}),
),
);
if (!compliant) {
toastService.showToast({
variant: "error",
message: i18nService.t("noPageAccess"),
});
return router.createUrlTree(["/"]);
}
return compliant;
};
}

View File

@@ -63,7 +63,9 @@
bitFormButton
type="submit"
>
@if (autoConfirmEnabled$ | async) {
@let autoConfirmEnabled = autoConfirmEnabled$ | async;
@let managePoliciesOnly = managePolicies$ | async;
@if (autoConfirmEnabled || managePoliciesOnly) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}

View File

@@ -22,6 +22,7 @@ import {
tap,
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -30,6 +31,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import {
DIALOG_DATA,
DialogConfig,
@@ -83,6 +85,12 @@ export class AutoConfirmPolicyDialogComponent
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
protected managePolicies$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
getById(this.data.organizationId),
map((organization) => (!organization?.isAdmin && organization?.canManagePolicies) ?? false),
);
private readonly submitPolicy: Signal<TemplateRef<unknown> | undefined> = viewChild("step0");
private readonly openExtension: Signal<TemplateRef<unknown> | undefined> = viewChild("step1");
@@ -105,6 +113,7 @@ export class AutoConfirmPolicyDialogComponent
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
private router: Router,
) {
@@ -146,22 +155,34 @@ export class AutoConfirmPolicyDialogComponent
tap((singleOrgPolicyEnabled) =>
this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled),
),
map((singleOrgPolicyEnabled) => [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
{
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
},
]),
switchMap((singleOrgPolicyEnabled) => this.buildMultiStepSubmit(singleOrgPolicyEnabled)),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
private buildMultiStepSubmit(singleOrgPolicyEnabled: boolean): Observable<MultiStepSubmit[]> {
return this.managePolicies$.pipe(
map((managePoliciesOnly) => {
const submitSteps = [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
];
if (!managePoliciesOnly) {
submitSteps.push({
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
});
}
return submitSteps;
}),
);
}
private async handleSubmit(singleOrgEnabled: boolean) {
if (!singleOrgEnabled) {
await this.submitSingleOrg();

View File

@@ -27,7 +27,7 @@
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
{{ "autoConfirmSingleOrgRequiredDesc" | i18n }}
</li>
<li>

View File

@@ -147,18 +147,6 @@ export class AppComponent implements OnDestroy, OnInit {
}
break;
}
case "premiumRequired": {
const premiumConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "upgrade" },
type: "success",
});
if (premiumConfirmed) {
await this.router.navigate(["settings/subscription/premium"]);
}
break;
}
case "emailVerificationRequired": {
const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "emailVerificationRequired" },

View File

@@ -113,14 +113,37 @@ export class RecoverTwoFactorComponent implements OnInit {
await this.router.navigate(["/settings/security/two-factor"]);
} catch (error: unknown) {
if (error instanceof ErrorResponse) {
this.logService.error("Error logging in automatically: ", error.message);
if (error.message.includes("Two-step token is invalid")) {
this.formGroup.get("recoveryCode")?.setErrors({
invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") },
if (
error.message.includes(
"Two-factor recovery has been performed. SSO authentication is required.",
)
) {
// [PM-21153]: Organization users with as SSO requirement need to be able to recover 2FA,
// but still be bound by the SSO requirement to log in. Therefore, we show a success toast for recovering 2FA,
// but then inform them that they need to log in via SSO and redirect them to the login page.
// The response tested here is a specific message for this scenario from request validation.
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("twoStepRecoverDisabled"),
});
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("ssoLoginIsRequired"),
});
await this.router.navigate(["/login"]);
} else {
this.validationService.showError(error.message);
this.logService.error("Error logging in automatically: ", error.message);
if (error.message.includes("Two-step token is invalid")) {
this.formGroup.get("recoveryCode")?.setErrors({
invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") },
});
} else {
this.validationService.showError(error.message);
}
}
} else {
this.logService.error("Error logging in automatically: ", error);

View File

@@ -96,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit {
this.loaded = true;
}
async premiumRequired() {
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
if (!canAccessPremium) {
this.messagingService.send("premiumRequired");
return;
}
}
edit = async (details: GranteeEmergencyAccess) => {
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, {

View File

@@ -3,7 +3,6 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import {
first,
firstValueFrom,
lastValueFrom,
Observable,
Subject,
@@ -264,13 +263,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
}
}
async premiumRequired() {
if (!(await firstValueFrom(this.canAccessPremium$))) {
this.messagingService.send("premiumRequired");
return;
}
}
protected getTwoFactorProviders() {
return this.twoFactorApiService.getTwoFactorProviders();
}

View File

@@ -1,21 +1,21 @@
import { inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
Router,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from "@angular/router";
import { Observable, of } from "rxjs";
import { from, Observable, of } from "rxjs";
import { switchMap, tap } from "rxjs/operators";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
/**
* CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired"
* message and blocks navigation.
* CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade
* flow and blocks navigation.
*/
export function hasPremiumGuard(): CanActivateFn {
return (
@@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn {
_state: RouterStateSnapshot,
): Observable<boolean | UrlTree> => {
const router = inject(Router);
const messagingService = inject(MessagingService);
const premiumUpgradePromptService = inject(PremiumUpgradePromptService);
const billingAccountProfileStateService = inject(BillingAccountProfileStateService);
const accountService = inject(AccountService);
@@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn {
? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
: of(false),
),
tap((userHasPremium: boolean) => {
switchMap((userHasPremium: boolean) => {
// Can't call async method inside observables so instead, wait for service then switch back to the boolean
if (!userHasPremium) {
messagingService.send("premiumRequired");
return from(premiumUpgradePromptService.promptForPremium()).pipe(
switchMap(() => of(userHasPremium)),
);
}
return of(userHasPremium);
}),
// Prevent trapping the user on the login page, since that's an awful UX flow
tap((userHasPremium: boolean) => {

View File

@@ -16,6 +16,11 @@ import {
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
@@ -28,12 +33,7 @@ import {
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "../../types/subscription-pricing-tier";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
@@ -91,7 +91,7 @@ export class PremiumVNextComponent {
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {

View File

@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import {
catchError,
combineLatest,
concatMap,
filter,
@@ -12,10 +13,9 @@ import {
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
catchError,
shareReplay,
} from "rxjs";
import { debounceTime } from "rxjs/operators";
@@ -23,6 +23,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
import { PersonalSubscriptionPricingTierIds } 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";
@@ -35,12 +37,10 @@ import {
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import {
tokenizablePaymentMethodToLegacyEnum,
NonTokenizablePaymentMethods,
tokenizablePaymentMethodToLegacyEnum,
} from "@bitwarden/web-vault/app/billing/payment/types";
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -137,7 +137,7 @@ export class PremiumComponent {
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: DefaultSubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();

View File

@@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
UpgradeAccountComponent,
UpgradeAccountStatus,

View File

@@ -4,6 +4,7 @@ import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
ButtonModule,
@@ -15,7 +16,6 @@ import {
import { AccountBillingClient, TaxClient } from "../../../clients";
import { BillingServicesModule } from "../../../services";
import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier";
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
import {

View File

@@ -4,15 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PricingCardComponent } from "@bitwarden/pricing";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component";
@@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => {
let sut: UpgradeAccountComponent;
let fixture: ComponentFixture<UpgradeAccountComponent>;
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
// Mock pricing tiers data
const mockPricingTiers: PersonalSubscriptionPricingTier[] = [
@@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => {
imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
],
})
.overrideComponent(UpgradeAccountComponent, {
@@ -170,7 +173,10 @@ describe("UpgradeAccountComponent", () => {
],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
],
})
.overrideComponent(UpgradeAccountComponent, {

View File

@@ -2,22 +2,23 @@ import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { catchError, of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonType, DialogModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "../../../types/subscription-pricing-tier";
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
export const UpgradeAccountStatus = {
Closed: "closed",
@@ -72,14 +73,26 @@ export class UpgradeAccountComponent implements OnInit {
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private destroyRef: DestroyRef,
) {}
ngOnInit(): void {
this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(takeUntilDestroyed(this.destroyRef))
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((plans) => {
this.setupCardDetails(plans);
this.loading.set(false);

View File

@@ -119,14 +119,13 @@ describe("UpgradeNavButtonComponent", () => {
);
});
it("should refresh token and sync after upgrading to premium", async () => {
it("should full sync after upgrading to premium", async () => {
const mockDialogRef = mock<DialogRef<UnifiedUpgradeDialogResult>>();
mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium });
mockDialogService.open.mockReturnValue(mockDialogRef);
await component.upgrade();
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});

View File

@@ -60,7 +60,6 @@ export class UpgradeNavButtonComponent {
const result = await lastValueFrom(dialogRef.closed);
if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
} else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) {
const redirectUrl = `/organizations/${result.organizationId}/vault`;

View File

@@ -11,6 +11,7 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
@@ -27,7 +28,6 @@ import {
NonTokenizedPaymentMethod,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";

View File

@@ -12,6 +12,11 @@ import {
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { LogService } from "@bitwarden/logging";
@@ -30,11 +35,6 @@ import {
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { mapAccountToSubscriber } from "../../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../../types/subscription-pricing-tier";
export type PlanDetails = {
tier: PersonalSubscriptionPricingTierId;

View File

@@ -24,6 +24,12 @@ import {
} from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
@@ -43,13 +49,7 @@ import {
TokenizedPaymentMethod,
} from "../../../payment/types";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import { BitwardenSubscriber } from "../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import {
PaymentFormValues,
@@ -128,7 +128,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private logService: LogService,
private destroyRef: DestroyRef,
@@ -145,29 +145,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => {
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
this.pricingTiers$
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((plans) => {
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
if (planDetails) {
this.selectedPlan = {
tier: this.selectedPlanId(),
details: planDetails,
};
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
if (planDetails) {
this.selectedPlan = {
tier: this.selectedPlanId(),
details: planDetails,
};
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),

View File

@@ -795,7 +795,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
: this.i18nService.t("organizationUpgraded"),
});
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {

View File

@@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
});
}
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {

View File

@@ -0,0 +1,147 @@
import { firstValueFrom } from "rxjs";
import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { newGuid } from "@bitwarden/guid";
import { UserId } from "@bitwarden/user-core";
import {
PREMIUM_INTEREST_KEY,
WebPremiumInterestStateService,
} from "./web-premium-interest-state.service";
describe("WebPremiumInterestStateService", () => {
let service: WebPremiumInterestStateService;
let stateProvider: FakeStateProvider;
let accountService: FakeAccountService;
const mockUserId = newGuid() as UserId;
const mockUserEmail = "user@example.com";
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
stateProvider = new FakeStateProvider(accountService);
service = new WebPremiumInterestStateService(stateProvider);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getPremiumInterest", () => {
it("should throw an error when userId is not provided", async () => {
const promise = service.getPremiumInterest(null);
await expect(promise).rejects.toThrow("UserId is required. Cannot get 'premiumInterest'.");
});
it("should return null when no value is set", async () => {
const result = await service.getPremiumInterest(mockUserId);
expect(result).toBeNull();
});
it("should return true when value is set to true", async () => {
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId);
const result = await service.getPremiumInterest(mockUserId);
expect(result).toBe(true);
});
it("should return false when value is set to false", async () => {
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, false, mockUserId);
const result = await service.getPremiumInterest(mockUserId);
expect(result).toBe(false);
});
it("should use getUserState$ to retrieve the value", async () => {
const getUserStateSpy = jest.spyOn(stateProvider, "getUserState$");
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId);
await service.getPremiumInterest(mockUserId);
expect(getUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, mockUserId);
});
});
describe("setPremiumInterest", () => {
it("should throw an error when userId is not provided", async () => {
const promise = service.setPremiumInterest(null, true);
await expect(promise).rejects.toThrow("UserId is required. Cannot set 'premiumInterest'.");
});
it("should set the value to true", async () => {
await service.setPremiumInterest(mockUserId, true);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBe(true);
});
it("should set the value to false", async () => {
await service.setPremiumInterest(mockUserId, false);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBe(false);
});
it("should update an existing value", async () => {
await service.setPremiumInterest(mockUserId, true);
await service.setPremiumInterest(mockUserId, false);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBe(false);
});
it("should use setUserState to store the value", async () => {
const setUserStateSpy = jest.spyOn(stateProvider, "setUserState");
await service.setPremiumInterest(mockUserId, true);
expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, true, mockUserId);
});
});
describe("clearPremiumInterest", () => {
it("should throw an error when userId is not provided", async () => {
const promise = service.clearPremiumInterest(null);
await expect(promise).rejects.toThrow("UserId is required. Cannot clear 'premiumInterest'.");
});
it("should clear the value by setting it to null", async () => {
await service.setPremiumInterest(mockUserId, true);
await service.clearPremiumInterest(mockUserId);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBeNull();
});
it("should use setUserState with null to clear the value", async () => {
const setUserStateSpy = jest.spyOn(stateProvider, "setUserState");
await service.setPremiumInterest(mockUserId, true);
await service.clearPremiumInterest(mockUserId);
expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, null, mockUserId);
});
});
});

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { BILLING_MEMORY, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import { UserId } from "@bitwarden/user-core";
export const PREMIUM_INTEREST_KEY = new UserKeyDefinition<boolean>(
BILLING_MEMORY,
"premiumInterest",
{
deserializer: (value: boolean) => value,
clearOn: ["lock", "logout"],
},
);
@Injectable()
export class WebPremiumInterestStateService implements PremiumInterestStateService {
constructor(private stateProvider: StateProvider) {}
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
if (!userId) {
throw new Error("UserId is required. Cannot get 'premiumInterest'.");
}
return await firstValueFrom(this.stateProvider.getUserState$(PREMIUM_INTEREST_KEY, userId));
}
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set 'premiumInterest'.");
}
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, premiumInterest, userId);
}
async clearPremiumInterest(userId: UserId): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot clear 'premiumInterest'.");
}
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId);
}
}

View File

@@ -226,7 +226,7 @@ export class StripeService {
base: {
color: null,
fontFamily:
'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
'Inter, "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
'"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
fontSize: "16px",
fontSmoothing: "antialiased",

View File

@@ -1,397 +0,0 @@
import { Injectable } from "@angular/core";
import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs";
import { catchError } from "rxjs/operators";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { BillingServicesModule } from "@bitwarden/web-vault/app/billing/services/billing-services.module";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
@Injectable({ providedIn: BillingServicesModule })
export class SubscriptionPricingService {
/**
* Fallback premium pricing used when the feature flag is disabled.
* These values represent the legacy pricing model and will not reflect
* server-side price changes. They are retained for backward compatibility
* during the feature flag rollout period.
*/
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private toastService: ToastService,
) {}
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
combineLatest([this.premium$, this.families$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
}),
);
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
}),
);
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
}),
);
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
this.billingApiService.getPremiumPlan(),
).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
throw error; // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
.pipe(
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
switchMap((fetchPremiumFromPricingService) =>
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
})),
)
: of({
seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
}),
),
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPrices.seat,
annualPricePerAdditionalStorageGB: premiumPrices.storage,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan.PasswordManager.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
return {
id: BusinessSubscriptionPricingTierIds.Free,
name: this.i18nService.t("planNameFree"),
description: this.i18nService.t("planDescFreeV2", "1"),
availableCadences: [],
passwordManager: {
type: "free",
features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
this.featureTranslations.alwaysFree(),
],
},
secretsManager: {
type: "free",
features: [
this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
],
},
};
}),
);
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
)!;
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}),
),
);
private showUnexpectedErrorToast() {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("unexpectedError"),
});
}
private featureTranslations = {
builtInAuthenticator: () => ({
key: "builtInAuthenticator",
value: this.i18nService.t("builtInAuthenticator"),
}),
emergencyAccess: () => ({
key: "emergencyAccess",
value: this.i18nService.t("emergencyAccess"),
}),
breachMonitoring: () => ({
key: "breachMonitoring",
value: this.i18nService.t("breachMonitoring"),
}),
andMoreFeatures: () => ({
key: "andMoreFeatures",
value: this.i18nService.t("andMoreFeatures"),
}),
premiumAccounts: () => ({
key: "premiumAccounts",
value: this.i18nService.t("premiumAccounts"),
}),
secureFileStorage: () => ({
key: "secureFileStorage",
value: this.i18nService.t("secureFileStorage"),
}),
familiesUnlimitedSharing: () => ({
key: "familiesUnlimitedSharing",
value: this.i18nService.t("familiesUnlimitedSharing"),
}),
familiesUnlimitedCollections: () => ({
key: "familiesUnlimitedCollections",
value: this.i18nService.t("familiesUnlimitedCollections"),
}),
familiesSharedStorage: () => ({
key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"),
}),
limitedUsersV2: (users: number) => ({
key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users),
}),
limitedCollectionsV2: (collections: number) => ({
key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections),
}),
alwaysFree: () => ({
key: "alwaysFree",
value: this.i18nService.t("alwaysFree"),
}),
twoSecretsIncluded: () => ({
key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"),
}),
projectsIncludedV2: (projects: number) => ({
key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects),
}),
secureItemSharing: () => ({
key: "secureItemSharing",
value: this.i18nService.t("secureItemSharing"),
}),
eventLogMonitoring: () => ({
key: "eventLogMonitoring",
value: this.i18nService.t("eventLogMonitoring"),
}),
directoryIntegration: () => ({
key: "directoryIntegration",
value: this.i18nService.t("directoryIntegration"),
}),
scimSupport: () => ({
key: "scimSupport",
value: this.i18nService.t("scimSupport"),
}),
unlimitedSecretsAndProjects: () => ({
key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"),
}),
includedMachineAccountsV2: (included: number) => ({
key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included),
}),
enterpriseSecurityPolicies: () => ({
key: "enterpriseSecurityPolicies",
value: this.i18nService.t("enterpriseSecurityPolicies"),
}),
passwordLessSso: () => ({
key: "passwordLessSso",
value: this.i18nService.t("passwordLessSso"),
}),
accountRecovery: () => ({
key: "accountRecovery",
value: this.i18nService.t("accountRecovery"),
}),
selfHostOption: () => ({
key: "selfHostOption",
value: this.i18nService.t("selfHostOption"),
}),
complimentaryFamiliesPlan: () => ({
key: "complimentaryFamiliesPlan",
value: this.i18nService.t("complimentaryFamiliesPlan"),
}),
unlimitedUsers: () => ({
key: "unlimitedUsers",
value: this.i18nService.t("unlimitedUsers"),
}),
strengthenCybersecurity: () => ({
key: "strengthenCybersecurity",
value: this.i18nService.t("strengthenCybersecurity"),
}),
boostProductivity: () => ({
key: "boostProductivity",
value: this.i18nService.t("boostProductivity"),
}),
seamlessIntegration: () => ({
key: "seamlessIntegration",
value: this.i18nService.t("seamlessIntegration"),
}),
};
}

View File

@@ -1,85 +0,0 @@
export const PersonalSubscriptionPricingTierIds = {
Premium: "premium",
Families: "families",
} as const;
export const BusinessSubscriptionPricingTierIds = {
Free: "free",
Teams: "teams",
Enterprise: "enterprise",
Custom: "custom",
} as const;
export const SubscriptionCadenceIds = {
Annually: "annually",
Monthly: "monthly",
} as const;
export type PersonalSubscriptionPricingTierId =
(typeof PersonalSubscriptionPricingTierIds)[keyof typeof PersonalSubscriptionPricingTierIds];
export type BusinessSubscriptionPricingTierId =
(typeof BusinessSubscriptionPricingTierIds)[keyof typeof BusinessSubscriptionPricingTierIds];
export type SubscriptionCadence =
(typeof SubscriptionCadenceIds)[keyof typeof SubscriptionCadenceIds];
type HasFeatures = {
features: { key: string; value: string }[];
};
type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number;
};
type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage & {
type: "standalone";
annualPrice: number;
};
type PackagedPasswordManager = HasFeatures &
HasAdditionalStorage & {
type: "packaged";
users: number;
annualPrice: number;
};
type FreePasswordManager = HasFeatures & {
type: "free";
};
type CustomPasswordManager = HasFeatures & {
type: "custom";
};
type ScalablePasswordManager = HasFeatures &
HasAdditionalStorage & {
type: "scalable";
annualPricePerUser: number;
};
type FreeSecretsManager = HasFeatures & {
type: "free";
};
type ScalableSecretsManager = HasFeatures & {
type: "scalable";
annualPricePerUser: number;
annualPricePerAdditionalServiceAccount: number;
};
export type PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierId;
name: string;
description: string;
availableCadences: Omit<SubscriptionCadence, "monthly">[]; // personal plans are only ever annual
passwordManager: StandalonePasswordManager | PackagedPasswordManager;
};
export type BusinessSubscriptionPricingTier = {
id: BusinessSubscriptionPricingTierId;
name: string;
description: string;
availableCadences: SubscriptionCadence[];
passwordManager: FreePasswordManager | ScalablePasswordManager | CustomPasswordManager;
secretsManager?: FreeSecretsManager | ScalableSecretsManager;
};

View File

@@ -18,7 +18,7 @@
<div bitTypography="body2">
{{ "accessing" | i18n }}:
<a [routerLink]="[]" [bitMenuTriggerFor]="environmentOptions">
<b class="tw-text-primary-600 tw-font-semibold">{{ currentRegion?.domain }}</b>
<b class="tw-text-primary-600 tw-font-medium">{{ currentRegion?.domain }}</b>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</a>
</div>

View File

@@ -14,6 +14,7 @@ import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import {
CLIENT_TYPE,
@@ -57,6 +58,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -96,6 +98,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import {
DefaultThemeStateService,
ThemeStateService,
@@ -129,6 +132,7 @@ import {
WebSetInitialPasswordService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
import { WebFileDownloadService } from "../core/web-file-download.service";
@@ -410,7 +414,21 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: PremiumUpgradePromptService,
useClass: WebVaultPremiumUpgradePromptService,
deps: [DialogService, Router],
deps: [
DialogService,
ConfigService,
AccountService,
ApiService,
SyncService,
BillingAccountProfileStateService,
PlatformUtilsService,
Router,
],
}),
safeProvider({
provide: PremiumInterestStateService,
useClass: WebPremiumInterestStateService,
deps: [StateProvider],
}),
safeProvider({
provide: AuthRequestAnsweringService,

View File

@@ -15,14 +15,12 @@
<h3 class="tw-mb-4 tw-text-xl tw-font-bold">{{ title }}</h3>
<p class="tw-mb-0">{{ description }}</p>
</bit-card-content>
<span
bitBadge
[variant]="requiresPremium ? 'success' : 'primary'"
class="tw-absolute tw-left-2 tw-top-2 tw-leading-none"
*ngIf="disabled"
>
<ng-container *ngIf="requiresPremium">{{ "premium" | i18n }}</ng-container>
<ng-container *ngIf="!requiresPremium">{{ "upgrade" | i18n }}</ng-container>
</span>
@if (requiresPremium) {
<app-premium-badge class="tw-absolute tw-left-2 tw-top-2"></app-premium-badge>
} @else if (requiresUpgrade) {
<span bitBadge variant="primary" class="tw-absolute tw-left-2 tw-top-2">
{{ "upgrade" | i18n }}
</span>
}
</bit-base-card>
</a>

View File

@@ -37,4 +37,8 @@ export class ReportCardComponent {
protected get requiresPremium() {
return this.variant == ReportVariant.RequiresPremium;
}
protected get requiresUpgrade() {
return this.variant == ReportVariant.RequiresUpgrade;
}
}

View File

@@ -1,14 +1,20 @@
import { importProvidersFrom } from "@angular/core";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import {
BadgeModule,
BaseCardComponent,
IconModule,
CardContentComponent,
I18nMockService,
IconModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
@@ -30,6 +36,37 @@ export default {
PremiumBadgeComponent,
BaseCardComponent,
],
providers: [
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "123",
}),
},
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
premium: "Premium",
upgrade: "Upgrade",
});
},
},
{
provide: BillingAccountProfileStateService,
useValue: {
hasPremiumFromAnySource$: () => of(false),
},
},
{
provide: PremiumUpgradePromptService,
useValue: {
promptForPremium: (orgId?: string) => {},
},
},
],
}),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],

View File

@@ -1,9 +1,13 @@
import { importProvidersFrom } from "@angular/core";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import {
BadgeModule,
BaseCardComponent,
@@ -33,6 +37,28 @@ export default {
BaseCardComponent,
],
declarations: [ReportCardComponent],
providers: [
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "123",
}),
},
},
{
provide: BillingAccountProfileStateService,
useValue: {
hasPremiumFromAnySource$: () => of(false),
},
},
{
provide: PremiumUpgradePromptService,
useValue: {
promptForPremium: (orgId?: string) => {},
},
},
],
}),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],

View File

@@ -1,6 +1,7 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { BaseCardComponent, CardContentComponent } from "@bitwarden/components";
import { SharedModule } from "../../../shared/shared.module";
@@ -9,7 +10,13 @@ import { ReportCardComponent } from "./report-card/report-card.component";
import { ReportListComponent } from "./report-list/report-list.component";
@NgModule({
imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent],
imports: [
CommonModule,
SharedModule,
BaseCardComponent,
CardContentComponent,
PremiumBadgeComponent,
],
declarations: [ReportCardComponent, ReportListComponent],
exports: [ReportCardComponent, ReportListComponent],
})

View File

@@ -9,6 +9,9 @@ export class MasterPasswordUnlockDataRequest {
email: string;
masterKeyAuthenticationHash: string;
/**
* Also known as masterKeyWrappedUserKey in other parts of the codebase
*/
masterKeyEncryptedUserKey: string;
masterPasswordHint?: string;
@@ -17,7 +20,7 @@ export class MasterPasswordUnlockDataRequest {
kdfConfig: KdfConfig,
email: string,
masterKeyAuthenticationHash: string,
masterKeyEncryptedUserKey: string,
masterKeyWrappedUserKey: string,
masterPasswordHash?: string,
) {
this.kdfType = kdfConfig.kdfType;
@@ -29,7 +32,7 @@ export class MasterPasswordUnlockDataRequest {
this.email = email;
this.masterKeyAuthenticationHash = masterKeyAuthenticationHash;
this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey;
this.masterKeyEncryptedUserKey = masterKeyWrappedUserKey;
this.masterPasswordHint = masterPasswordHash;
}
}

View File

@@ -12,7 +12,7 @@
<h1
bitTypography="h1"
noMargin
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1"
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1 tw-font-medium"
[title]="title || (routeData.titleId | i18n)"
>
<div class="tw-truncate">

View File

@@ -29,7 +29,7 @@
[href]="more.marketingRoute.route"
target="_blank"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
@@ -47,7 +47,7 @@
*ngIf="!more.marketingRoute.external"
[routerLink]="more.marketingRoute.route"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>

View File

@@ -14,7 +14,7 @@
[routerLink]="product.appRoute"
[ngClass]="
product.isActive
? 'tw-bg-primary-600 tw-font-bold !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600'
? 'tw-bg-primary-600 tw-font-medium !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600'
: ''
"
class="tw-group/product-link tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-600 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700"

View File

@@ -20,10 +20,12 @@
*ngIf="showSubscription$ | async"
></bit-nav-item>
<bit-nav-item [text]="'domainRules' | i18n" route="settings/domain-rules"></bit-nav-item>
<bit-nav-item
[text]="'emergencyAccess' | i18n"
route="settings/emergency-access"
></bit-nav-item>
@if (showEmergencyAccess()) {
<bit-nav-item
[text]="'emergencyAccess' | i18n"
route="settings/emergency-access"
></bit-nav-item>
}
<billing-free-families-nav-item></billing-free-families-nav-item>
</bit-nav-group>
</app-side-nav>

View File

@@ -1,14 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Component, OnInit, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { RouterModule } from "@angular/router";
import { Observable, switchMap } from "rxjs";
import { combineLatest, map, Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { IconModule } from "@bitwarden/components";
@@ -32,6 +38,7 @@ import { WebLayoutModule } from "./web-layout.module";
})
export class UserLayoutComponent implements OnInit {
protected readonly logo = PasswordManagerLogo;
protected readonly showEmergencyAccess: Signal<boolean>;
protected hasFamilySponsorshipAvailable$: Observable<boolean>;
protected showSponsoredFamilies$: Observable<boolean>;
protected showSubscription$: Observable<boolean>;
@@ -40,12 +47,33 @@ export class UserLayoutComponent implements OnInit {
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private accountService: AccountService,
private policyService: PolicyService,
private configService: ConfigService,
) {
this.showSubscription$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.canViewSubscription$(account.id),
),
);
this.showEmergencyAccess = toSignal(
combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
),
),
]).pipe(
map(([enabled, policyAppliesToUser]) => {
if (!enabled || !policyAppliesToUser) {
return true;
}
return false;
}),
),
);
}
async ngOnInit() {

View File

@@ -47,11 +47,13 @@ import {
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { flagEnabled, Flags } from "../utils/flags";
import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard";
import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";
@@ -687,11 +689,13 @@ const routes: Routes = [
{
path: "",
component: EmergencyAccessComponent,
canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)],
data: { titleId: "emergencyAccess" } satisfies RouteDataProperties,
},
{
path: ":id",
component: EmergencyAccessViewComponent,
canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)],
data: { titleId: "emergencyAccess" } satisfies RouteDataProperties,
},
],

View File

@@ -21,7 +21,7 @@
<div class="tw-col-span-3">
<div class="tw-border tw-border-solid tw-border-secondary-300 tw-rounded" data-testid="filters">
<div
class="tw-bg-background-alt tw-border-0 tw-border-b tw-border-solid tw-border-secondary-100 tw-rounded-t tw-px-5 tw-py-2.5 tw-font-semibold tw-uppercase"
class="tw-bg-background-alt tw-border-0 tw-border-b tw-border-solid tw-border-secondary-100 tw-rounded-t tw-px-5 tw-py-2.5 tw-font-medium tw-uppercase"
data-testid="filters-header"
>
{{ "filters" | i18n }}

View File

@@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
@@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => {
{ provide: LogService, useValue: {} },
{ provide: CipherService, useValue: {} },
{ provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } },
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
{ provide: Router, useValue: {} },
{ provide: ActivatedRoute, useValue: {} },
{
@@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => {
{ provide: ApiService, useValue: {} },
{ provide: EventCollectionService, useValue: {} },
{ provide: RoutedVaultFilterService, useValue: {} },
{ provide: SyncService, useValue: {} },
{ provide: PlatformUtilsService, useValue: {} },
],
}).compileComponents();

View File

@@ -65,6 +65,7 @@ import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -326,6 +327,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private organizationWarningsService: OrganizationWarningsService,
private policyService: PolicyService,
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit() {
@@ -867,7 +869,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
if (cipher.organizationId == null && !this.canAccessPremium) {
this.messagingService.send("premiumRequired");
await this.premiumUpgradePromptService.promptForPremium();
return;
} else if (cipher.organizationId != null) {
const org = await firstValueFrom(

View File

@@ -2,8 +2,19 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { lastValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
@@ -13,13 +24,27 @@ describe("WebVaultPremiumUpgradePromptService", () => {
let service: WebVaultPremiumUpgradePromptService;
let dialogServiceMock: jest.Mocked<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<VaultItemDialogResult>>;
let dialogRefMock: jest.Mocked<DialogRef>;
let configServiceMock: jest.Mocked<ConfigService>;
let accountServiceMock: jest.Mocked<AccountService>;
let apiServiceMock: jest.Mocked<ApiService>;
let syncServiceMock: jest.Mocked<SyncService>;
let billingAccountProfileServiceMock: jest.Mocked<BillingAccountProfileStateService>;
let platformUtilsServiceMock: jest.Mocked<PlatformUtilsService>;
beforeEach(() => {
dialogServiceMock = {
openSimpleDialog: jest.fn(),
} as unknown as jest.Mocked<DialogService>;
configServiceMock = {
getFeatureFlag: jest.fn().mockReturnValue(false),
} as unknown as jest.Mocked<ConfigService>;
accountServiceMock = {
activeAccount$: of({ id: "user-123" }),
} as unknown as jest.Mocked<AccountService>;
routerMock = {
navigate: jest.fn(),
} as unknown as jest.Mocked<Router>;
@@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<VaultItemDialogResult>>;
apiServiceMock = {
refreshIdentityToken: jest.fn().mockReturnValue({}),
} as unknown as jest.Mocked<ApiService>;
syncServiceMock = {
fullSync: jest.fn(),
} as unknown as jest.Mocked<SyncService>;
billingAccountProfileServiceMock = {
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
} as unknown as jest.Mocked<BillingAccountProfileStateService>;
platformUtilsServiceMock = {
isSelfHost: jest.fn().mockReturnValue(false),
} as unknown as jest.Mocked<PlatformUtilsService>;
TestBed.configureTestingModule({
providers: [
WebVaultPremiumUpgradePromptService,
{ provide: DialogService, useValue: dialogServiceMock },
{ provide: Router, useValue: routerMock },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: ConfigService, useValue: configServiceMock },
{ provide: AccountService, useValue: accountServiceMock },
{ provide: ApiService, useValue: apiServiceMock },
{ provide: SyncService, useValue: syncServiceMock },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileServiceMock },
{ provide: PlatformUtilsService, useValue: platformUtilsServiceMock },
],
});
@@ -84,4 +131,144 @@ describe("WebVaultPremiumUpgradePromptService", () => {
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(dialogRefMock.close).not.toHaveBeenCalled();
});
describe("premium status check", () => {
it("should not prompt if user already has premium (feature flag off)", async () => {
configServiceMock.getFeatureFlag.mockReturnValue(Promise.resolve(false));
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
await service.promptForPremium();
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it("should not prompt if user already has premium (feature flag on)", async () => {
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).not.toHaveBeenCalled();
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
});
describe("new premium upgrade dialog with post-upgrade actions", () => {
beforeEach(() => {
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
});
describe("when self-hosted", () => {
beforeEach(() => {
platformUtilsServiceMock.isSelfHost.mockReturnValue(true);
});
it("should navigate to subscription page instead of opening dialog", async () => {
await service.promptForPremium();
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
});
});
describe("when not self-hosted", () => {
beforeEach(() => {
platformUtilsServiceMock.isSelfHost.mockReturnValue(false);
});
it("should full sync when user upgrades to premium", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
});
it("should full sync when user upgrades to families", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToFamilies }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
});
it("should not refresh or sync when user closes dialog without upgrading", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(apiServiceMock.refreshIdentityToken).not.toHaveBeenCalled();
expect(syncServiceMock.fullSync).not.toHaveBeenCalled();
});
it("should not open new dialog if organizationId is provided", async () => {
const organizationId = "test-org-id" as OrganizationId;
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
const openSpy = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
openSpy.mockClear();
await service.promptForPremium(organizationId);
expect(openSpy).not.toHaveBeenCalled();
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
});
});
});
});

View File

@@ -1,10 +1,21 @@
import { Injectable, Optional } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
import { firstValueFrom, lastValueFrom, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
@@ -15,14 +26,44 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
constructor(
private dialogService: DialogService,
private configService: ConfigService,
private accountService: AccountService,
private apiService: ApiService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
@Optional() private dialog?: DialogRef<VaultItemDialogResult>,
) {}
private readonly subscriptionPageRoute = "settings/subscription/premium";
/**
* Prompts the user for a premium upgrade.
*/
async promptForPremium(organizationId?: OrganizationId) {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const hasPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
if (hasPremium) {
// Already has premium, don't prompt
return;
}
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
// Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed
// as a part of https://bitwarden.atlassian.net/browse/PM-25507
if (showNewDialog && !organizationId) {
await this.promptForPremiumVNext(account);
return;
}
let confirmed = false;
let route: string[] | null = null;
@@ -44,7 +85,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
type: "success",
});
if (confirmed) {
route = ["settings/subscription/premium"];
route = [this.subscriptionPageRoute];
}
}
@@ -57,4 +98,31 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
}
}
private async promptForPremiumVNext(account: Account) {
await (this.platformUtilsService.isSelfHost()
? this.redirectToSubscriptionPage()
: this.openUpgradeDialog(account));
}
private async redirectToSubscriptionPage() {
await this.router.navigate([this.subscriptionPageRoute]);
}
private async openUpgradeDialog(account: Account) {
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: {
account,
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
await this.syncService.fullSync(true);
}
}
}

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
font-size="18" text-anchor="middle">
Loading...
</text>

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
<text fill="%23333333" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
font-size="18" text-anchor="middle">
Loading...
</text>

View File

@@ -26,6 +26,9 @@
"reviewAtRiskPasswords": {
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
},
"reviewAtRiskLoginsPrompt": {
"message": "Review at-risk logins"
},
"dataLastUpdated": {
"message": "Data last updated: $DATE$",
"placeholders": {
@@ -127,6 +130,9 @@
}
}
},
"criticalApplicationsMarked": {
"message": "critical applications marked"
},
"countOfCriticalApplications": {
"message": "$COUNT$ critical applications",
"placeholders": {
@@ -235,6 +241,15 @@
"applicationsMarkedAsCriticalSuccess": {
"message": "Applications marked as critical"
},
"criticalApplicationsMarkedSuccess": {
"message": "$COUNT$ applications marked as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"applicationsMarkedAsCriticalFail": {
"message": "Failed to mark applications as critical"
},
@@ -259,6 +274,12 @@
"membersWithAccessToAtRiskItemsForCriticalApps": {
"message": "Members with access to at-risk items for critical applications"
},
"membersWithAtRiskPasswords": {
"message": "Members with at-risk passwords"
},
"membersWillReceiveNotification": {
"message": "Members will receive a notification to resolve at-risk logins through the browser extension."
},
"membersAtRiskCount": {
"message": "$COUNT$ members at-risk",
"placeholders": {
@@ -352,8 +373,11 @@
"prioritizeCriticalApplications": {
"message": "Prioritize critical applications"
},
"atRiskItems": {
"message": "At-risk items"
"selectCriticalApplicationsDescription": {
"message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks."
},
"clickIconToMarkAppAsCritical": {
"message": "Click the star icon to mark an app as critical"
},
"markAsCriticalPlaceholder": {
"message": "Mark as critical functionality will be implemented in a future update"
@@ -361,15 +385,6 @@
"applicationReviewSaved": {
"message": "Application review saved"
},
"applicationsMarkedAsCritical": {
"message": "$COUNT$ applications marked as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"newApplicationsReviewed": {
"message": "New applications reviewed"
},
@@ -841,6 +856,9 @@
"favorites": {
"message": "Favorites"
},
"taskSummary": {
"message": "Task summary"
},
"types": {
"message": "Types"
},
@@ -5814,8 +5832,8 @@
"autoConfirmSingleOrgRequired": {
"message": "Single organization policy required. "
},
"autoConfirmSingleOrgRequiredDescription": {
"message": "Anyone part of more than one organization will have their access revoked until they leave the other organizations."
"autoConfirmSingleOrgRequiredDesc": {
"message": "All members must only belong to this organization to activate this automation."
},
"autoConfirmSingleOrgExemption": {
"message": "Single organization policy will extend to all roles. "
@@ -6547,11 +6565,11 @@
"updateWeakMasterPasswordWarning": {
"message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."
},
"automaticAppLogin": {
"message": "Automatically log in users for allowed applications"
"automaticAppLoginWithSSO": {
"message": "Automatic login with SSO"
},
"automaticAppLoginDesc": {
"message": "Login forms will automatically be filled and submitted for apps launched from your configured identity provider."
"automaticAppLoginWithSSODesc": {
"message": "Extend SSO security and convenience to unmanaged apps. When users launch an app from your identity provider, their login details are automatically filled and submitted, creating a one-click, secure flow from the identity provider to the app."
},
"automaticAppLoginIdpHostLabel": {
"message": "Identity provider host"
@@ -7326,6 +7344,9 @@
"accessDenied": {
"message": "Access denied. You do not have permission to view this page."
},
"noPageAccess": {
"message": "You do not have access to this page"
},
"masterPassword": {
"message": "Master password"
},
@@ -9784,6 +9805,9 @@
"assignTasks": {
"message": "Assign tasks"
},
"assignTasksToMembers": {
"message": "Assign tasks to members for guided resolution"
},
"assignToCollections": {
"message": "Assign to collections"
},

View File

@@ -86,11 +86,11 @@
*/
@layer components {
.tw-h1 {
@apply tw-text-3xl tw-font-semibold;
@apply tw-text-3xl tw-font-medium;
}
.tw-btn {
@apply tw-font-semibold tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none;
@apply tw-font-medium tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none;
}
.tw-btn-secondary {
@@ -100,7 +100,7 @@
}
.tw-link {
@apply tw-font-semibold hover:tw-underline hover:tw-decoration-1;
@apply tw-font-medium hover:tw-underline hover:tw-decoration-1;
@apply tw-text-primary-600 hover:tw-text-primary-700 focus-visible:before:tw-ring-primary-600;
}

View File

@@ -30,7 +30,7 @@
color: rgb(var(--color-primary-600));
}
&.active {
font-weight: bold;
font-weight: 500;
}
}
}
@@ -59,7 +59,7 @@
> .filter-buttons {
.filter-button {
color: rgb(var(--color-primary-600));
font-weight: bold;
font-weight: 500;
}
.edit-button {