1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 02:23:25 +00:00

Merge branch 'main' into km/auto-kdf-qa

This commit is contained in:
Bernd Schoolmann
2025-11-12 15:22:58 +01:00
730 changed files with 23463 additions and 8142 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

@@ -2,17 +2,12 @@
<app-side-nav variant="secondary" *ngIf="organization$ | async as organization">
<bit-nav-logo [openIcon]="logo" route="." [label]="'adminConsole' | i18n"></bit-nav-logo>
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-group
<bit-nav-item
icon="bwi-dashboard"
*ngIf="organization.useRiskInsights && organization.canAccessReports"
*ngIf="organization.useAccessIntelligence && organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
>
<bit-nav-item
[text]="'riskInsights' | i18n"
route="access-intelligence/risk-insights"
></bit-nav-item>
</bit-nav-group>
></bit-nav-item>
<bit-nav-item
icon="bwi-collection-shared"
[text]="'collections' | i18n"

View File

@@ -124,7 +124,7 @@
>
<i class="bwi bwi-2x bwi-business tw-text-primary-600"></i>
<p class="tw-font-bold tw-mt-2">
<p class="tw-font-medium tw-mt-2">
{{ "upgradeEventLogTitleMessage" | i18n }}
</p>
<p>

View File

@@ -34,7 +34,7 @@
(change)="toggleAllVisible($event)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
@@ -64,7 +64,7 @@
<td bitCell (click)="check(g)" class="tw-cursor-pointer">
<input type="checkbox" bitCheckbox [(ngModel)]="g.checked" />
</td>
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)">
<td bitCell class="tw-cursor-pointer tw-font-medium" (click)="edit(g)">
<button type="button" bitLink>
{{ g.details.name }}
</button>

View File

@@ -94,7 +94,7 @@
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>

View File

@@ -38,11 +38,11 @@
<div class="tw-flex tw-flex-col">
@let showBadge = firstTimeDialog();
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-28 tw-my-2"> {{ "availableNow" | i18n }}</span>
<span bitBadge variant="info" class="tw-w-[99px] tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span>
{{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }}
@if (!firstTimeDialog) {
{{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }}
@if (!showBadge) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy.name | i18n }}
</span>
@@ -63,7 +63,9 @@
bitFormButton
type="submit"
>
@if (autoConfirmEnabled$ | async) {
@let autoConfirmEnabled = autoConfirmEnabled$ | async;
@let managePoliciesOnly = managePoliciesOnly$ | async;
@if (autoConfirmEnabled || managePoliciesOnly) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}

View File

@@ -22,6 +22,8 @@ import {
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common";
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 +32,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 +86,15 @@ export class AutoConfirmPolicyDialogComponent
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
// Users with manage policies custom permission should not see the dialog's second step since
// they do not have permission to configure the setting. This will only allow them to configure
// the policy.
protected managePoliciesOnly$: 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,8 +117,10 @@ export class AutoConfirmPolicyDialogComponent
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
private router: Router,
private autoConfirmService: AutomaticUserConfirmationService,
) {
super(
data,
@@ -146,22 +160,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.managePoliciesOnly$.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();
@@ -185,6 +211,17 @@ export class AutoConfirmPolicyDialogComponent
autoConfirmRequest,
);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const currentAutoConfirmState = await firstValueFrom(
this.autoConfirmService.configuration$(userId),
);
await this.autoConfirmService.upsert(userId, {
...currentAutoConfirmState,
showSetupDialog: false,
});
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
@@ -197,7 +234,6 @@ export class AutoConfirmPolicyDialogComponent
private async submitSingleOrg(): Promise<void> {
const singleOrgRequest: PolicyRequest = {
type: PolicyType.SingleOrg,
enabled: true,
data: null,
};

View File

@@ -109,7 +109,6 @@ export abstract class BasePolicyEditComponent implements OnInit {
}
const request: PolicyRequest = {
type: this.policy.type,
enabled: this.enabled.value ?? false,
data: this.buildRequestData(),
};

View File

@@ -2,3 +2,6 @@ export { PoliciesComponent } from "./policies.component";
export { ossPolicyEditRegister } from "./policy-edit-register";
export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
export { POLICY_EDIT_REGISTER } from "./policy-register-token";
export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component";
export { AutoConfirmPolicy } from "./policy-edit-definitions";
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";

View File

@@ -7,7 +7,7 @@
<ul class="tw-mb-6 tw-pl-6">
<li>
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmAcceptSecurityRiskTitle" | i18n }}
</span>
{{ "autoConfirmAcceptSecurityRiskDescription" | i18n }}
@@ -19,19 +19,19 @@
<li>
@if (singleOrgEnabled$ | async) {
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmSingleOrgExemption" | i18n }}
</span>
} @else {
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
{{ "autoConfirmSingleOrgRequiredDesc" | i18n }}
</li>
<li>
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmNoEmergencyAccess" | i18n }}
</span>
{{ "autoConfirmNoEmergencyAccessDescription" | i18n }}
@@ -47,12 +47,12 @@
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
</div>
<ol>
<li>1. {{ "autoConfirmStep1" | i18n }}</li>
<li>1. {{ "autoConfirmExtension1" | i18n }}</li>
<li>
2. {{ "autoConfirmStep2a" | i18n }}
2. {{ "autoConfirmExtension2" | i18n }}
<strong>
{{ "autoConfirmStep2b" | i18n }}
{{ "autoConfirmExtension3" | i18n }}
</strong>
</li>
</ol>

View File

@@ -74,7 +74,6 @@ export class vNextOrganizationDataOwnershipPolicyComponent
const request: VNextPolicyRequest = {
policy: {
type: this.policy.type,
enabled: this.enabled.value ?? false,
data: this.buildRequestData(),
},

View File

@@ -100,7 +100,7 @@
<ng-template #readOnlyPerm>
<div
*ngIf="item.readonly || disabled"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-medium tw-text-muted"
[title]="permissionLabelId(item.readonlyPermission) | i18n"
>
{{ permissionLabelId(item.readonlyPermission) | i18n }}

View File

@@ -15,8 +15,9 @@ import { PreValidateSponsorshipResponse } from "@bitwarden/common/admin-console/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -43,7 +44,7 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy {
return;
}
value.plan = PlanType.FamiliesAnnually;
value.plan = this._familyPlan;
value.productTier = ProductTierType.Families;
value.acceptingSponsorship = true;
value.planSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise;
@@ -63,13 +64,14 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy {
_selectedFamilyOrganizationId = "";
private _destroy = new Subject<void>();
private _familyPlan: PlanType;
formGroup = this.formBuilder.group({
selectedFamilyOrganizationId: ["", Validators.required],
});
constructor(
private router: Router,
private platformUtilsService: PlatformUtilsService,
private configService: ConfigService,
private i18nService: I18nService,
private route: ActivatedRoute,
private apiService: ApiService,
@@ -120,6 +122,13 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy {
this.badToken = !this.preValidateSponsorshipResponse.isTokenValid;
}
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
this._familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
this.loading = false;
});

View File

@@ -1,11 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { first } from "rxjs/operators";
import { PlanType, ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { OrganizationPlansComponent } from "../../billing";
import { HeaderModule } from "../../layouts/header/header.module";
@@ -17,15 +19,27 @@ import { SharedModule } from "../../shared";
templateUrl: "create-organization.component.html",
imports: [SharedModule, OrganizationPlansComponent, HeaderModule],
})
export class CreateOrganizationComponent {
export class CreateOrganizationComponent implements OnInit {
protected secretsManager = false;
protected plan: PlanType = PlanType.Free;
protected productTier: ProductTierType = ProductTierType.Free;
constructor(private route: ActivatedRoute) {
constructor(
private route: ActivatedRoute,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
const familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => {
if (qParams.plan === "families" || qParams.productTier == ProductTierType.Families) {
this.plan = PlanType.FamiliesAnnually;
this.plan = familyPlan;
this.productTier = ProductTierType.Families;
} else if (qParams.plan === "teams" || qParams.productTier == ProductTierType.Teams) {
this.plan = PlanType.TeamsAnnually;

View File

@@ -8,6 +8,7 @@ import { Subject, filter, firstValueFrom, map, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { LockService } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,7 +17,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -58,8 +58,8 @@ export class AppComponent implements OnDestroy, OnInit {
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private ngZone: NgZone,
private vaultTimeoutService: VaultTimeoutService,
private keyService: KeyService,
private lockService: LockService,
private collectionService: CollectionService,
private searchService: SearchService,
private serverNotificationsService: ServerNotificationsService,
@@ -113,11 +113,13 @@ export class AppComponent implements OnDestroy, OnInit {
// note: the message.logoutReason isn't consumed anymore because of the process reload clearing any toasts.
await this.logOut(message.redirect);
break;
case "lockVault":
await this.vaultTimeoutService.lock();
case "lockVault": {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.lockService.lock(userId);
break;
}
case "locked":
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "lockedUrl":
break;
@@ -147,18 +149,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" },
@@ -279,7 +269,7 @@ export class AppComponent implements OnDestroy, OnInit {
await this.router.navigate(["/"]);
}
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
// Normally we would need to reset the loading state to false or remove the layout_frontend
// class from the body here, but the process reload completely reloads the app so

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

@@ -8,6 +8,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -68,6 +70,12 @@ describe("EmergencyViewDialogComponent", () => {
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
{ provide: CipherRiskService, useValue: mock<CipherRiskService>() },
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
],
})
.overrideComponent(EmergencyViewDialogComponent, {
@@ -78,7 +86,6 @@ describe("EmergencyViewDialogComponent", () => {
provide: ChangeLoginPasswordService,
useValue: ChangeLoginPasswordService,
},
{ provide: ConfigService, useValue: ConfigService },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
@@ -89,7 +96,6 @@ describe("EmergencyViewDialogComponent", () => {
provide: ChangeLoginPasswordService,
useValue: mock<ChangeLoginPasswordService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},

View File

@@ -2,7 +2,10 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { SessionTimeoutComponent } from "../../../key-management/session-timeout/session-timeout.component";
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
import { PasswordSettingsComponent } from "./password-settings/password-settings.component";
@@ -15,7 +18,20 @@ const routes: Routes = [
component: SecurityComponent,
data: { titleId: "security" },
children: [
{ path: "", pathMatch: "full", redirectTo: "password" },
{ path: "", pathMatch: "full", redirectTo: "session-timeout" },
{
path: "session-timeout",
component: SessionTimeoutComponent,
canActivate: [
canAccessFeature(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
true,
"/settings/security/password",
false,
),
],
data: { titleId: "sessionTimeoutHeader" },
},
{
path: "password",
component: PasswordSettingsComponent,

View File

@@ -1,8 +1,11 @@
<app-header>
<bit-tab-nav-bar slot="tabs">
<ng-container *ngIf="showChangePassword">
@if (consolidatedSessionTimeoutComponent$ | async) {
<bit-tab-link route="session-timeout">{{ "sessionTimeoutHeader" | i18n }}</bit-tab-link>
}
@if (showChangePassword) {
<bit-tab-link [route]="changePasswordRoute">{{ "masterPassword" | i18n }}</bit-tab-link>
</ng-container>
}
<bit-tab-link route="two-factor">{{ "twoStepLogin" | i18n }}</bit-tab-link>
<bit-tab-link route="device-management">{{ "devices" | i18n }}</bit-tab-link>
<bit-tab-link route="security-keys">{{ "keys" | i18n }}</bit-tab-link>

View File

@@ -1,6 +1,9 @@
import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
@@ -14,8 +17,16 @@ import { SharedModule } from "../../../shared";
export class SecurityComponent implements OnInit {
showChangePassword = true;
changePasswordRoute = "password";
consolidatedSessionTimeoutComponent$: Observable<boolean>;
constructor(private userVerificationService: UserVerificationService) {}
constructor(
private userVerificationService: UserVerificationService,
private configService: ConfigService,
) {
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
);
}
async ngOnInit() {
this.showChangePassword = await this.userVerificationService.hasMasterPassword();

View File

@@ -27,7 +27,7 @@ export abstract class TwoFactorSetupMethodBaseComponent {
enabled = false;
authed = false;
protected hashedSecret: string | undefined;
protected secret: string | undefined;
protected verificationType: VerificationType | undefined;
protected componentName = "";
@@ -42,7 +42,7 @@ export abstract class TwoFactorSetupMethodBaseComponent {
) {}
protected auth(authResponse: AuthResponseBase) {
this.hashedSecret = authResponse.secret;
this.secret = authResponse.secret;
this.verificationType = authResponse.verificationType;
this.authed = true;
}
@@ -132,12 +132,12 @@ export abstract class TwoFactorSetupMethodBaseComponent {
protected async buildRequestModel<T extends SecretVerificationRequest>(
requestClass: new () => T,
) {
if (this.hashedSecret === undefined || this.verificationType === undefined) {
if (this.secret === undefined || this.verificationType === undefined) {
throw new Error("User verification data is missing");
}
return this.userVerificationService.buildRequest(
{
secret: this.hashedSecret,
secret: this.secret,
type: this.verificationType,
},
requestClass,

View File

@@ -17,10 +17,10 @@
<ul class="bwi-ul">
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
<i class="bwi bwi-li bwi-key"></i>
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-bold">
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-medium">
{{ "webAuthnkeyX" | i18n: (i + 1).toString() }}
</span>
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-bold">
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-medium">
{{ k.name }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">

View File

@@ -45,7 +45,7 @@
</div>
</div>
</div>
<p bitTypography="body1" class="tw-font-bold tw-mb-2">{{ "nfcSupport" | i18n }}</p>
<p bitTypography="body1" class="tw-font-medium tw-mb-2">{{ "nfcSupport" | i18n }}</p>
<bit-form-control [disableMargin]="true">
<bit-label>{{ "twoFactorYubikeySupportsNfc" | i18n }}</bit-label>
<input bitCheckbox type="checkbox" formControlName="anyKeyHasNfc" />

View File

@@ -53,7 +53,7 @@
<div bit-item-content class="tw-px-4">
<h3 class="tw-mb-0">
<div
class="tw-font-semibold tw-text-base"
class="tw-font-medium tw-text-base"
[style]="p.enabled || p.premium ? 'display:inline-block' : ''"
>
{{ p.name }}

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,15 +1,14 @@
import { Component, EventEmitter, Inject, Output } from "@angular/core";
import { Component, Inject } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
@@ -45,14 +44,10 @@ type TwoFactorVerifyDialogData = {
export class TwoFactorVerifyComponent {
type: TwoFactorProviderType;
organizationId: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAuthed = new EventEmitter<AuthResponse<TwoFactorResponse>>();
formPromise: Promise<TwoFactorResponse> | undefined;
protected formGroup = new FormGroup({
secret: new FormControl<Verification | null>(null),
secret: new FormControl<VerificationWithSecret | null>(null),
});
invalidSecret: boolean = false;
@@ -69,24 +64,19 @@ export class TwoFactorVerifyComponent {
submit = async () => {
try {
let hashedSecret = "";
if (!this.formGroup.value.secret) {
throw new Error("Secret is required");
}
const secret = this.formGroup.value.secret!;
this.formPromise = this.userVerificationService.buildRequest(secret).then((request) => {
hashedSecret =
secret.type === VerificationType.MasterPassword
? request.masterPasswordHash
: request.otp;
return this.apiCall(request);
});
const response = await this.formPromise;
this.dialogRef.close({
response: response,
secret: hashedSecret,
secret: secret.secret,
verificationType: secret.type,
});
} catch (e) {

View File

@@ -19,7 +19,6 @@
</span>
</ng-container>
</ng-container>
<span bitBadge variant="warning" class="!tw-align-middle">{{ "beta" | i18n }}</span>
</span>
<ng-container *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
@@ -34,7 +33,7 @@
<table *ngIf="hasCredentials" class="tw-mb-5">
<tr *ngFor="let credential of credentials">
<td class="tw-p-2 tw-pl-0 tw-font-semibold">{{ credential.name }}</td>
<td class="tw-p-2 tw-pl-0 tw-font-medium">{{ credential.name }}</td>
<td class="tw-p-2 tw-pr-10 tw-text-left">
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled">
<i class="bwi bwi-lock-encrypted"></i>

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core";
import { Subject, switchMap, takeUntil } from "rxjs";
@@ -36,6 +34,8 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
protected credentials?: WebauthnLoginCredentialView[];
protected loading = true;
protected requireSsoPolicyEnabled = false;
constructor(
private webauthnService: WebauthnLoginAdminService,
private dialogService: DialogService,
@@ -43,25 +43,6 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
private accountService: AccountService,
) {}
@HostBinding("attr.aria-busy")
get ariaBusy() {
return this.loading ? "true" : "false";
}
get hasCredentials() {
return this.credentials && this.credentials.length > 0;
}
get hasData() {
return this.credentials !== undefined;
}
get limitReached() {
return this.credentials?.length >= this.MaxCredentialCount;
}
requireSsoPolicyEnabled = false;
ngOnInit(): void {
this.accountService.activeAccount$
.pipe(
@@ -90,6 +71,23 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
@HostBinding("attr.aria-busy")
get ariaBusy() {
return this.loading ? "true" : "false";
}
get hasCredentials() {
return (this.credentials?.length ?? 0) > 0;
}
get hasData() {
return this.credentials !== undefined;
}
get limitReached() {
return (this.credentials?.length ?? 0) >= this.MaxCredentialCount;
}
protected createCredential() {
openCreateCredentialDialog(this.dialogService, {});
}

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

@@ -2,15 +2,15 @@ import { inject, NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { componentRouteSwap } from "@bitwarden/angular/utils/component-route-swap";
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 { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumVNextComponent } from "./premium/premium-vnext.component";
import { PremiumComponent } from "./premium/premium.component";
import { CloudHostedPremiumVNextComponent } from "./premium/cloud-hosted-premium-vnext.component";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -26,22 +26,55 @@ const routes: Routes = [
component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" },
},
...componentRouteSwap(
PremiumComponent,
PremiumVNextComponent,
() => {
const configService = inject(ConfigService);
const platformUtilsService = inject(PlatformUtilsService);
/**
* Three-Route Matching Strategy for /premium:
*
* Routes are evaluated in order using canMatch guards. The first route that matches will be selected.
*
* 1. Self-Hosted Environment → SelfHostedPremiumComponent
* - Matches when platformUtilsService.isSelfHost() === true
*
* 2. Cloud-Hosted + Feature Flag Enabled → CloudHostedPremiumVNextComponent
* - Only evaluated if Route 1 doesn't match (not self-hosted)
* - Matches when PM24033PremiumUpgradeNewDesign feature flag === true
*
* 3. Cloud-Hosted + Feature Flag Disabled → CloudHostedPremiumComponent (Fallback)
* - No canMatch guard, so this always matches as the fallback route
* - Used when neither Route 1 nor Route 2 match
*/
// Route 1: Self-Hosted -> SelfHostedPremiumComponent
{
path: "premium",
component: SelfHostedPremiumComponent,
data: { titleId: "goPremium" },
canMatch: [
() => {
const platformUtilsService = inject(PlatformUtilsService);
return platformUtilsService.isSelfHost();
},
],
},
// Route 2: Cloud Hosted + FF -> CloudHostedPremiumVNextComponent
{
path: "premium",
component: CloudHostedPremiumVNextComponent,
data: { titleId: "goPremium" },
canMatch: [
() => {
const configService = inject(ConfigService);
return configService
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
.pipe(map((flagValue) => flagValue === true && !platformUtilsService.isSelfHost()));
},
{
data: { titleId: "goPremium" },
path: "premium",
},
),
return configService
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
.pipe(map((flagValue) => flagValue === true));
},
],
},
// Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback)
{
path: "premium",
component: CloudHostedPremiumComponent,
data: { titleId: "goPremium" },
},
{
path: "payment-details",
component: AccountPaymentDetailsComponent,

View File

@@ -11,7 +11,7 @@ import { BillingSharedModule } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module";
import { PremiumComponent } from "./premium/premium.component";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -28,7 +28,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
SubscriptionComponent,
BillingHistoryViewComponent,
UserSubscriptionComponent,
PremiumComponent,
CloudHostedPremiumComponent,
],
})
export class IndividualBillingModule {}

View File

@@ -7,7 +7,7 @@
</span>
</div>
<h2 *ngIf="!isSelfHost" class="tw-mt-2 tw-text-4xl">
<h2 class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">

View File

@@ -11,12 +11,17 @@ import {
of,
shareReplay,
switchMap,
take,
} 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
BadgeModule,
@@ -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,
@@ -52,7 +52,7 @@ const RouteParamValues = {
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./premium-vnext.component.html",
templateUrl: "./cloud-hosted-premium-vnext.component.html",
standalone: true,
imports: [
CommonModule,
@@ -64,7 +64,7 @@ const RouteParamValues = {
PricingCardComponent,
],
})
export class PremiumVNextComponent {
export class CloudHostedPremiumVNextComponent {
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
@@ -81,22 +81,18 @@ export class PremiumVNextComponent {
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
protected isSelfHost = false;
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
@@ -187,10 +183,13 @@ export class PremiumVNextComponent {
this.shouldShowUpgradeDialogOnInit$
.pipe(
switchMap(async (shouldShowUpgradeDialogOnInit) => {
take(1),
switchMap((shouldShowUpgradeDialogOnInit) => {
if (shouldShowUpgradeDialogOnInit) {
from(this.openUpgradeDialog("Premium"));
return from(this.openUpgradeDialog("Premium"));
}
// Return an Observable that completes immediately when dialog should not be shown
return of(void 0);
}),
takeUntilDestroyed(this.destroyRef),
)

View File

@@ -10,7 +10,7 @@
} @else {
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<h2 bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
@@ -51,7 +51,7 @@
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
<p bitTypography="body1" class="tw-mb-0">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
@@ -65,24 +65,9 @@
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<form [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">

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,9 +23,10 @@ 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";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
@@ -35,21 +36,19 @@ 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
@Component({
templateUrl: "./premium.component.html",
templateUrl: "./cloud-hosted-premium.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
})
export class PremiumComponent {
export class CloudHostedPremiumComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
@@ -121,7 +120,6 @@ export class PremiumComponent {
);
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected readonly familyPlanMaxUserCount = 6;
constructor(
@@ -130,17 +128,14 @@ export class PremiumComponent {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: DefaultSubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
@@ -231,7 +226,10 @@ export class PremiumComponent {
const formData = new FormData();
formData.append("paymentMethodType", paymentMethodType.toString());
formData.append("paymentToken", paymentToken);
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
formData.append(
"additionalStorageGb",
(this.formGroup.value.additionalStorage ?? 0).toString(),
);
formData.append("country", this.formGroup.value.billingAddress.country);
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
@@ -239,12 +237,4 @@ export class PremiumComponent {
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
}
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
}

View File

@@ -0,0 +1,49 @@
<bit-container>
<bit-section>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<a
bitButton
href="{{ cloudPremiumPageUrl$ | async }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section>
<individual-self-hosting-license-uploader (onLicenseFileUploaded)="onLicenseFileUploaded()" />
</bit-section>
</bit-container>

View File

@@ -0,0 +1,79 @@
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, map, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { BillingSharedModule } from "@bitwarden/web-vault/app/billing/shared";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./self-hosted-premium.component.html",
imports: [SharedModule, BillingSharedModule],
})
export class SelfHostedPremiumComponent {
cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe(
map((url) => `${url}/#/settings/subscription/premium`),
);
hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
onLicenseFileUploaded = async () => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscription();
};
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private router: Router,
private toastService: ToastService,
) {
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(),
switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => {
if (hasPremiumFromAnyOrganization) {
return this.navigateToVault();
}
if (hasPremiumPersonally) {
return this.navigateToSubscription();
}
return of(true);
}),
)
.subscribe();
}
navigateToSubscription = () =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
navigateToVault = () => this.router.navigate(["/vault"]);
}

View File

@@ -7,16 +7,21 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
import { UnifiedUpgradePromptService } from "./unified-upgrade-prompt.service";
import {
UnifiedUpgradePromptService,
PREMIUM_MODAL_DISMISSED_KEY,
} from "./unified-upgrade-prompt.service";
describe("UnifiedUpgradePromptService", () => {
let sut: UnifiedUpgradePromptService;
@@ -29,6 +34,8 @@ describe("UnifiedUpgradePromptService", () => {
const mockOrganizationService = mock<OrganizationService>();
const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
const mockPlatformUtilsService = mock<PlatformUtilsService>();
const mockStateProvider = mock<StateProvider>();
const mockLogService = mock<LogService>();
/**
* Creates a mock DialogRef that implements the required properties for testing
@@ -59,6 +66,8 @@ describe("UnifiedUpgradePromptService", () => {
mockDialogService,
mockOrganizationService,
mockPlatformUtilsService,
mockStateProvider,
mockLogService,
);
}
@@ -72,6 +81,7 @@ describe("UnifiedUpgradePromptService", () => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockStateProvider.getUserState$.mockReturnValue(of(false));
setupTestService();
});
@@ -82,6 +92,7 @@ describe("UnifiedUpgradePromptService", () => {
describe("displayUpgradePromptConditionally", () => {
beforeEach(() => {
accountSubject.next(mockAccount); // Reset account to mockAccount
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockDialogOpen.mockReset();
mockReset(mockDialogService);
@@ -90,11 +101,16 @@ describe("UnifiedUpgradePromptService", () => {
mockReset(mockVaultProfileService);
mockReset(mockSyncService);
mockReset(mockOrganizationService);
mockReset(mockStateProvider);
// Mock sync service methods
mockSyncService.fullSync.mockResolvedValue(true);
mockSyncService.lastSync$.mockReturnValue(of(new Date()));
mockReset(mockPlatformUtilsService);
// Default: modal has not been dismissed
mockStateProvider.getUserState$.mockReturnValue(of(false));
mockStateProvider.setUserState.mockResolvedValue(undefined);
});
it("should subscribe to account and feature flag observables when checking display conditions", async () => {
// Arrange
@@ -256,5 +272,71 @@ describe("UnifiedUpgradePromptService", () => {
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has previously dismissed the modal", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockStateProvider.getUserState$.mockReturnValue(of(true)); // User has dismissed
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should save dismissal state when user closes the dialog", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
PREMIUM_MODAL_DISMISSED_KEY,
true,
mockAccount.id,
);
});
it("should not save dismissal state when user upgrades to premium", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.UpgradedToPremium };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockStateProvider.setUserState).not.toHaveBeenCalled();
});
});
});

View File

@@ -8,16 +8,29 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
// State key for tracking premium modal dismissal
export const PREMIUM_MODAL_DISMISSED_KEY = new UserKeyDefinition<boolean>(
BILLING_DISK,
"premiumModalDismissed",
{
deserializer: (value: boolean) => value,
clearOn: [],
},
);
@Injectable({
providedIn: "root",
})
@@ -32,6 +45,8 @@ export class UnifiedUpgradePromptService {
private dialogService: DialogService,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private stateProvider: StateProvider,
private logService: LogService,
) {}
private shouldShowPrompt$: Observable<boolean> = this.accountService.activeAccount$.pipe(
@@ -45,22 +60,36 @@ export class UnifiedUpgradePromptService {
return of(false);
}
const isProfileLessThanFiveMinutesOld = from(
const isProfileLessThanFiveMinutesOld$ = from(
this.isProfileLessThanFiveMinutesOld(account.id),
);
const hasOrganizations = from(this.hasOrganizations(account.id));
const hasOrganizations$ = from(this.hasOrganizations(account.id));
const hasDismissedModal$ = this.hasDismissedModal$(account.id);
return combineLatest([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
isProfileLessThanFiveMinutesOld$,
hasOrganizations$,
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
hasDismissedModal$,
]).pipe(
map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, isFlagEnabled]) => {
return (
isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && isFlagEnabled
);
}),
map(
([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
hasPremium,
isFlagEnabled,
hasDismissed,
]) => {
return (
isProfileLessThanFiveMinutesOld &&
!hasOrganizations &&
!hasPremium &&
isFlagEnabled &&
!hasDismissed
);
},
),
);
}),
take(1),
@@ -114,6 +143,17 @@ export class UnifiedUpgradePromptService {
const result = await firstValueFrom(this.unifiedUpgradeDialogRef.closed);
this.unifiedUpgradeDialogRef = null;
// Save dismissal state when the modal is closed without upgrading
if (result?.status === UnifiedUpgradeDialogStatus.Closed) {
try {
await this.stateProvider.setUserState(PREMIUM_MODAL_DISMISSED_KEY, true, account.id);
} catch (error) {
// Log the error but don't block the dialog from closing
// The modal will still close properly even if persistence fails
this.logService.error("Failed to save premium modal dismissal state:", error);
}
}
// Return the result or null if the dialog was dismissed without a result
return result || null;
}
@@ -145,4 +185,15 @@ export class UnifiedUpgradePromptService {
return memberOrganizations.length > 0;
}
/**
* Checks if the user has previously dismissed the premium modal
* @param userId User ID to check
* @returns Observable that emits true if modal was dismissed, false otherwise
*/
private hasDismissedModal$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUserState$(PREMIUM_MODAL_DISMISSED_KEY, userId)
.pipe(map((dismissed) => dismissed ?? false));
}
}

View File

@@ -1,16 +1,18 @@
import { Component, input, output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
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,
@@ -58,6 +60,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
let component: UnifiedUpgradeDialogComponent;
let fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockRouter = mock<Router>();
const mockPremiumInterestStateService = mock<PremiumInterestStateService>();
const mockAccount: Account = {
id: "user-id" as UserId,
@@ -74,11 +78,16 @@ describe("UnifiedUpgradeDialogComponent", () => {
};
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -121,6 +130,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -161,6 +172,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -191,11 +204,11 @@ describe("UnifiedUpgradeDialogComponent", () => {
});
describe("previousStep", () => {
it("should go back to plan selection and clear selected plan", () => {
it("should go back to plan selection and clear selected plan", async () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
component["previousStep"]();
await component["previousStep"]();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
@@ -222,6 +235,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -241,4 +256,169 @@ describe("UnifiedUpgradeDialogComponent", () => {
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
});
});
describe("onComplete with premium interest", () => {
it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => {
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
mockRouter.navigate.mockResolvedValue(true);
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await component["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
it("should not clear premium interest when upgrading to families", async () => {
const result: UpgradePaymentResult = {
status: "upgradedToFamilies",
organizationId: "org-123",
};
await component["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled();
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-123",
});
});
it("should use standard redirect when no premium interest exists", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
mockRouter.navigate.mockResolvedValue(true);
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await customComponent["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith([
"/settings/subscription/user-subscription",
]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
});
describe("onCloseClicked with premium interest", () => {
it("should clear premium interest when modal is closed", async () => {
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
await component["onCloseClicked"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("previousStep with premium interest", () => {
it("should NOT clear premium interest when navigating between steps", async () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
await component["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
});
it("should clear premium interest when backing out of dialog completely", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
await customComponent["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
});

View File

@@ -3,7 +3,9 @@ import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
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 +17,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 {
@@ -94,6 +95,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
private dialogRef: DialogRef<UnifiedUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams,
private router: Router,
private premiumInterestStateService: PremiumInterestStateService,
) {}
ngOnInit(): void {
@@ -110,7 +112,9 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.selectedPlan.set(planId);
this.nextStep();
}
protected onCloseClicked(): void {
protected async onCloseClicked(): Promise<void> {
// Clear premium interest when user closes/abandons modal
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
@@ -124,18 +128,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
}
}
protected previousStep(): void {
protected async previousStep(): Promise<void> {
// If we are on the payment step and there was no initial step, go back to plan selection this is to prevent
// going back to payment step if the dialog was opened directly to payment step
if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) {
this.step.set(UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(null);
} else {
// Clear premium interest when backing out of dialog completely
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
}
protected onComplete(result: UpgradePaymentResult): void {
protected async onComplete(result: UpgradePaymentResult): Promise<void> {
let status: UnifiedUpgradeDialogStatus;
switch (result.status) {
case "upgradedToPremium":
@@ -153,6 +159,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.close({ status, organizationId: result.organizationId });
// Check premium interest and route to vault for marketing-initiated premium upgrades
if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest(
this.params.account.id,
);
if (hasPremiumInterest) {
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
await this.router.navigate(["/vault"]);
return; // Exit early, don't use redirectOnCompletion
}
}
// Use redirectOnCompletion for standard upgrade flows
if (
this.params.redirectOnCompletion &&
(status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
@@ -162,7 +181,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
? `/organizations/${result.organizationId}/vault`
: "/settings/subscription/user-subscription";
void this.router.navigate([redirectUrl]);
await this.router.navigate([redirectUrl]);
}
}

View File

@@ -16,7 +16,7 @@
</header>
<div class="tw-px-14 tw-pb-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-semibold tw-text-[32px]">
<h1 class="tw-font-medium tw-text-[32px]">
{{ dialogTitle() | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">

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

@@ -4,7 +4,7 @@
is not supported by the button in the CL. -->
<button
type="button"
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-semibold tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-medium tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
(click)="upgrade()"
>
<i class="bwi bwi-premium" aria-hidden="true"></i>

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

@@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing";
import { mock, mockReset } from "jest-mock-extended";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
@@ -11,6 +10,8 @@ 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
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";
@@ -36,13 +36,12 @@ describe("UpgradePaymentService", () => {
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockTaxClient = mock<TaxClient>();
const mockLogService = mock<LogService>();
const mockApiService = mock<ApiService>();
const mockSyncService = mock<SyncService>();
const mockOrganizationService = mock<OrganizationService>();
const mockAccountService = mock<AccountService>();
const mockSubscriberBillingClient = mock<SubscriberBillingClient>();
const mockConfigService = mock<ConfigService>();
mockApiService.refreshIdentityToken.mockResolvedValue({});
mockSyncService.fullSync.mockResolvedValue(true);
let sut: UpgradePaymentService;
@@ -134,10 +133,10 @@ describe("UpgradePaymentService", () => {
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: TaxClient, useValue: mockTaxClient },
{ provide: LogService, useValue: mockLogService },
{ provide: ApiService, useValue: mockApiService },
{ provide: SyncService, useValue: mockSyncService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: ConfigService, useValue: mockConfigService },
],
});
@@ -183,11 +182,11 @@ describe("UpgradePaymentService", () => {
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
mockConfigService,
);
// Act & Assert
@@ -235,11 +234,11 @@ describe("UpgradePaymentService", () => {
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
mockConfigService,
);
// Act & Assert
@@ -269,11 +268,11 @@ describe("UpgradePaymentService", () => {
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
mockConfigService,
);
// Act & Assert
@@ -304,11 +303,11 @@ describe("UpgradePaymentService", () => {
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
mockConfigService,
);
// Act & Assert
@@ -330,11 +329,11 @@ describe("UpgradePaymentService", () => {
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
mockConfigService,
);
// Act & Assert
service?.accountCredit$.subscribe({
@@ -385,11 +384,11 @@ describe("UpgradePaymentService", () => {
mockAccountBillingClient,
mockTaxClient,
mockLogService,
mockApiService,
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
mockConfigService,
);
// Act & Assert
@@ -482,7 +481,6 @@ describe("UpgradePaymentService", () => {
mockTokenizedPaymentMethod,
mockBillingAddress,
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
@@ -501,7 +499,6 @@ describe("UpgradePaymentService", () => {
accountCreditPaymentMethod,
mockBillingAddress,
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
@@ -569,7 +566,7 @@ describe("UpgradePaymentService", () => {
billingEmail: "test@example.com",
},
plan: {
type: PlanType.FamiliesAnnually,
type: PlanType.FamiliesAnnually2025,
passwordManagerSeats: 6,
},
payment: {
@@ -582,10 +579,73 @@ describe("UpgradePaymentService", () => {
}),
"user-id",
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
it("should use FamiliesAnnually2025 plan when feature flag is disabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(false);
mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({
id: "org-id",
name: "Test Organization",
billingEmail: "test@example.com",
} as OrganizationResponse);
// Act
await sut.upgradeToFamilies(
mockAccount,
mockFamiliesPlanDetails,
mockTokenizedPaymentMethod,
{
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
},
);
// Assert
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith(
expect.objectContaining({
plan: {
type: PlanType.FamiliesAnnually2025,
passwordManagerSeats: 6,
},
}),
"user-id",
);
});
it("should use FamiliesAnnually plan when feature flag is enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(true);
mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({
id: "org-id",
name: "Test Organization",
billingEmail: "test@example.com",
} as OrganizationResponse);
// Act
await sut.upgradeToFamilies(
mockAccount,
mockFamiliesPlanDetails,
mockTokenizedPaymentMethod,
{
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
},
);
// Assert
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith(
expect.objectContaining({
plan: {
type: PlanType.FamiliesAnnually,
passwordManagerSeats: 6,
},
}),
"user-id",
);
});
it("should throw error if password manager seats are 0", async () => {
// Arrange
const invalidPlanDetails: PlanDetails = {

View File

@@ -1,7 +1,6 @@
import { Injectable } from "@angular/core";
import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
@@ -12,6 +11,13 @@ 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { LogService } from "@bitwarden/logging";
@@ -30,11 +36,6 @@ import {
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { mapAccountToSubscriber } from "../../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../../types/subscription-pricing-tier";
export type PlanDetails = {
tier: PersonalSubscriptionPricingTierId;
@@ -59,11 +60,11 @@ export class UpgradePaymentService {
private accountBillingClient: AccountBillingClient,
private taxClient: TaxClient,
private logService: LogService,
private apiService: ApiService,
private syncService: SyncService,
private organizationService: OrganizationService,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private configService: ConfigService,
) {}
userIsOwnerOfFreeOrg$: Observable<boolean> = this.accountService.activeAccount$.pipe(
@@ -169,6 +170,12 @@ export class UpgradePaymentService {
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
const passwordManagerSeats = this.getPasswordManagerSeats(planDetails);
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
const familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
const subscriptionInformation: SubscriptionInformation = {
organization: {
@@ -176,7 +183,7 @@ export class UpgradePaymentService {
billingEmail: account.email, // Use account email as billing email
},
plan: {
type: PlanType.FamiliesAnnually,
type: familyPlan,
passwordManagerSeats: passwordManagerSeats,
},
payment: {
@@ -224,7 +231,6 @@ export class UpgradePaymentService {
}
private async refreshAndSync(): Promise<void> {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
}

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading()">
<span bitDialogTitle class="tw-font-semibold">{{ upgradeToMessage }}</span>
<span bitDialogTitle class="tw-font-medium">{{ upgradeToMessage() }}</span>
<ng-container bitDialogContent>
<section>
@if (isFamiliesPlan) {
@@ -50,17 +50,15 @@
</section>
<section>
@if (passwordManager) {
<billing-cart-summary
#cartSummaryComponent
[passwordManager]="passwordManager"
[estimatedTax]="estimatedTax$ | async"
></billing-cart-summary>
@if (isFamiliesPlan) {
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}
</p>
}
<billing-cart-summary
#cartSummaryComponent
[passwordManager]="passwordManager()"
[estimatedTax]="estimatedTax$ | async"
></billing-cart-summary>
@if (isFamiliesPlan) {
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}
</p>
}
</section>
</ng-container>

View File

@@ -1,6 +1,7 @@
import {
AfterViewInit,
Component,
computed,
DestroyRef,
input,
OnInit,
@@ -24,11 +25,17 @@ 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";
import { LogService } from "@bitwarden/logging";
import { CartSummaryComponent, LineItem } from "@bitwarden/pricing";
import { CartSummaryComponent } from "@bitwarden/pricing";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
@@ -43,13 +50,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,
@@ -104,8 +105,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
protected readonly account = input.required<Account>();
protected goBack = output<void>();
protected complete = output<UpgradePaymentResult>();
protected selectedPlan: PlanDetails | null = null;
protected hasEnoughAccountCredit$!: Observable<boolean>;
readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent);
readonly cartSummaryComponent = viewChild.required(CartSummaryComponent);
@@ -116,19 +115,30 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
protected readonly selectedPlan = signal<PlanDetails | null>(null);
protected readonly loading = signal(true);
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
protected readonly upgradeToMessage = signal("");
// Cart Summary data
protected passwordManager!: LineItem;
protected estimatedTax$!: Observable<number>;
protected readonly passwordManager = computed(() => {
if (!this.selectedPlan()) {
return { name: "", cost: 0, quantity: 0, cadence: "year" as const };
}
// Display data
protected upgradeToMessage = "";
return {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan()!.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year" as const,
};
});
protected hasEnoughAccountCredit$!: Observable<boolean>;
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
protected estimatedTax$!: Observable<number>;
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private logService: LogService,
private destroyRef: DestroyRef,
@@ -145,29 +155,36 @@ 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.set({
tier: this.selectedPlanId(),
details: planDetails,
});
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
this.upgradeToMessage.set(
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),
@@ -215,7 +232,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
return;
}
if (!this.selectedPlan) {
if (!this.selectedPlan()) {
throw new Error("No plan selected");
}
@@ -247,7 +264,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
private async processUpgrade(): Promise<UpgradePaymentResult> {
if (!this.selectedPlan) {
if (!this.selectedPlan()) {
throw new Error("No plan selected");
}
@@ -295,7 +312,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
const response = await this.upgradePaymentService.upgradeToFamilies(
this.account(),
this.selectedPlan!,
this.selectedPlan()!,
paymentMethod,
paymentFormValues,
);
@@ -331,7 +348,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
// Create an observable for tax calculation
private refreshSalesTax$(): Observable<number> {
if (this.formGroup.invalid || !this.selectedPlan) {
if (this.formGroup.invalid || !this.selectedPlan()) {
return of(this.INITIAL_TAX_VALUE);
}
@@ -340,7 +357,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
return of(this.INITIAL_TAX_VALUE);
}
return from(
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress),
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan()!, billingAddress),
).pipe(
catchError((error: unknown) => {
this.logService.error("Tax calculation failed:", error);

View File

@@ -37,7 +37,7 @@
></button>
</bit-form-field>
<div class="tw-mt-2 tw-text-sm tw-text-muted" *ngIf="showLastSyncText">
<b class="tw-font-semibold">{{ "lastSync" | i18n }}:</b>
<b class="tw-font-medium">{{ "lastSync" | i18n }}:</b>
{{ lastSyncDate | date: "medium" }}
</div>
<div class="tw-mt-2 tw-text-sm tw-text-danger" *ngIf="showAwaitingSyncText">

View File

@@ -1,12 +1,12 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ dialogHeaderName }}
</span>
<div bitDialogContent>
<p>{{ "upgradePlans" | i18n }}</p>
<div class="tw-mb-3 tw-flex tw-justify-between">
<span [hidden]="isSubscriptionCanceled" class="tw-text-lg tw-pr-1 tw-font-bold">{{
<span [hidden]="isSubscriptionCanceled" class="tw-text-lg tw-pr-1 tw-font-medium">{{
"selectAPlan" | i18n
}}</span>
<!-- Discount Badge -->
@@ -57,7 +57,7 @@
selectableProduct.productTier === productTypes.Enterprise &&
!isSubscriptionCanceled
"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-medium tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': selectableProduct === selectedPlan,
'tw-bg-secondary-100': !(selectableProduct === selectedPlan),
@@ -73,7 +73,7 @@
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-medium tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{
selectableProduct.nameLocalizationKey | i18n
@@ -91,7 +91,7 @@
<ng-container
*ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"
>
<b class="tw-text-lg tw-font-semibold">
<b class="tw-text-lg tw-font-medium">
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12
@@ -106,7 +106,7 @@
: ("monthPerMember" | i18n)
}}</span
>
<b class="tw-text-sm tw-font-semibold">
<b class="tw-text-sm tw-font-medium">
<ng-container
*ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption"
>
@@ -128,7 +128,7 @@
selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
>
<b class="tw-text-lg tw-font-semibold"
<b class="tw-text-lg tw-font-medium"
>{{
"costPerMember"
| i18n
@@ -155,7 +155,7 @@
"
>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
@@ -182,7 +182,7 @@
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenSecretsManager" | i18n }}
@@ -222,7 +222,7 @@
</ng-container>
<ng-template #fullFeatureList>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
@@ -274,7 +274,7 @@
</li>
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="
organization.useSecretsManager &&
selectableProduct.productTier !== productTypes.Families
@@ -385,7 +385,7 @@
</ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
<span class="tw-font-medium"
>{{ "total" | i18n }}:
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD</span
>
@@ -402,7 +402,7 @@
<!-- SM + PM and PM only cost summary -->
<div *ngIf="totalOpened && !isSecretsManagerTrial()" class="tw-flex tw-flex-wrap tw-gap-4">
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Annually">
<p class="tw-font-semibold tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -487,7 +487,7 @@
</ng-container>
</p>
<!-- secrets manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -569,7 +569,7 @@
</p>
</bit-hint>
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Monthly">
<p class="tw-font-semibold tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -642,7 +642,7 @@
</ng-container>
</p>
<!-- secrets manager summary for monthly -->
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -727,7 +727,7 @@
<div *ngIf="totalOpened && isSecretsManagerTrial()" class="tw-flex tw-flex-wrap tw-gap-4">
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Annually">
<!-- secrets manager summary for annual -->
<p class="tw-font-semibold tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -788,7 +788,7 @@
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
<!-- password manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -846,7 +846,7 @@
</bit-hint>
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Monthly">
<!-- secrets manager summary for monthly -->
<p class="tw-font-semibold tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -903,7 +903,7 @@
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
<!-- password manager summary for monthly -->
<p class="tw-font-semibold tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -972,7 +972,7 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">
<span class="tw-font-medium">
{{ "estimatedTax" | i18n }}
</span>
<span>
@@ -986,14 +986,12 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">
<span class="tw-font-medium">
{{ "total" | i18n }}
</span>
<span>
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold">
/ {{ selectedPlanInterval | i18n }}</span
>
<span class="tw-text-xs tw-font-medium"> / {{ selectedPlanInterval | i18n }}</span>
</span>
</p>
</bit-hint>

View File

@@ -31,7 +31,9 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
@@ -149,6 +151,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
protected estimatedTax: number = 0;
private _productTier = ProductTierType.Free;
private _familyPlan: PlanType;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@@ -247,6 +250,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private organizationWarningsService: OrganizationWarningsService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
@@ -296,10 +300,16 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
}
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
this._familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) {
const upgradedPlan = this.passwordManagerPlans.find((plan) =>
this.currentPlan.productTier === ProductTierType.Free
? plan.type === PlanType.FamiliesAnnually
? plan.type === this._familyPlan
: plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1,
);
@@ -544,9 +554,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
if (this.acceptingSponsorship) {
const familyPlan = this.passwordManagerPlans.find(
(plan) => plan.type === PlanType.FamiliesAnnually,
);
const familyPlan = this.passwordManagerPlans.find((plan) => plan.type === this._familyPlan);
this.discount = familyPlan.PasswordManager.basePrice;
return [familyPlan];
}
@@ -562,6 +570,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
plan.productTier === ProductTierType.TeamsStarter ||
(this.selectedInterval === PlanInterval.Annually && plan.isAnnual) ||
(this.selectedInterval === PlanInterval.Monthly && !plan.isAnnual)) &&
(plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) &&
(!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) &&
this.planIsEnabled(plan),
);
@@ -795,7 +804,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) {
@@ -927,7 +935,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) {
const upgradedPlan = this.passwordManagerPlans.find((plan) => {
if (this.currentPlan.productTier === ProductTierType.Free) {
return plan.type === PlanType.FamiliesAnnually;
return plan.type === this._familyPlan;
}
if (
@@ -1025,6 +1033,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => {
switch (planType) {
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
return { tier: "families", cadence: "annually" };
case PlanType.TeamsMonthly:
return { tier: "teams", cadence: "monthly" };

View File

@@ -36,8 +36,10 @@ import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/commo
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -126,6 +128,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
private _productTier = ProductTierType.Free;
private _familyPlan: PlanType;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@@ -217,6 +220,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private configService: ConfigService,
) {
this.selfHosted = this.platformUtilsService.isSelfHost();
}
@@ -256,10 +260,16 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
}
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
this._familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) {
const upgradedPlan = this.passwordManagerPlans.find((plan) =>
this.currentPlan.productTier === ProductTierType.Free
? plan.type === PlanType.FamiliesAnnually
? plan.type === this._familyPlan
: plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1,
);
@@ -378,9 +388,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
get selectableProducts() {
if (this.acceptingSponsorship) {
const familyPlan = this.passwordManagerPlans.find(
(plan) => plan.type === PlanType.FamiliesAnnually,
);
const familyPlan = this.passwordManagerPlans.find((plan) => plan.type === this._familyPlan);
this.discount = familyPlan.PasswordManager.basePrice;
return [familyPlan];
}
@@ -397,6 +405,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
plan.productTier === ProductTierType.TeamsStarter) &&
(!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) &&
(!this.hasProvider || plan.productTier !== ProductTierType.TeamsStarter) &&
(plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) &&
((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) ||
(this.isProviderQualifiedFor2020Plan() &&
Allowed2020PlansForLegacyProviders.includes(plan.type))),
@@ -413,6 +422,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.passwordManagerPlans?.filter(
(plan) =>
plan.productTier === selectedProductTierType &&
(plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) &&
((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) ||
(this.isProviderQualifiedFor2020Plan() &&
Allowed2020PlansForLegacyProviders.includes(plan.type))),
@@ -675,7 +685,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
});
}
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
@@ -714,6 +723,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private getPlanFromLegacyEnum(): OrganizationSubscriptionPlan {
switch (this.formGroup.value.plan) {
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
return { tier: "families", cadence: "annually" };
case PlanType.TeamsMonthly:
return { tier: "teams", cadence: "monthly" };
@@ -986,7 +996,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) {
const upgradedPlan = this.passwordManagerPlans.find((plan) => {
if (this.currentPlan.productTier === ProductTierType.Free) {
return plan.type === PlanType.FamiliesAnnually;
return plan.type === this._familyPlan;
}
if (

View File

@@ -241,7 +241,7 @@
<div class="tw-size-56 tw-content-center">
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
</div>
<p class="tw-font-bold">{{ "billingManagedByProvider" | i18n: userOrg.providerName }}</p>
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: userOrg.providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
</div>
</ng-template>

View File

@@ -300,6 +300,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString());
} else if (
this.sub.planType === PlanType.FamiliesAnnually ||
this.sub.planType === PlanType.FamiliesAnnually2025 ||
this.sub.planType === PlanType.FamiliesAnnually2019 ||
this.sub.planType === PlanType.TeamsStarter2023 ||
this.sub.planType === PlanType.TeamsStarter

View File

@@ -130,7 +130,7 @@
<bit-label class="tw-mb-6 tw-block" *ngIf="!showAutomaticSyncAndManualUpload">
{{ "licenseAndBillingManagementDesc" | i18n }}
</bit-label>
<h3 *ngIf="showAutomaticSyncAndManualUpload" class="tw-font-semibold tw-mt-6">
<h3 *ngIf="showAutomaticSyncAndManualUpload" class="tw-font-medium tw-mt-6">
{{ "uploadLicense" | i18n }}
</h3>
<app-update-license

View File

@@ -12,7 +12,7 @@ import { GearIcon } from "@bitwarden/assets/svg";
<div class="tw-size-56 tw-content-center">
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
</div>
<p class="tw-font-bold">{{ "billingManagedByProvider" | i18n: providerName }}</p>
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
</div>`,
standalone: false,

View File

@@ -58,7 +58,7 @@ const positiveNumberValidator =
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "addCredit" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -24,7 +24,7 @@ type DialogParams = {
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "changePaymentMethod" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -41,7 +41,7 @@ type DialogResult =
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "editBillingAddress" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -35,7 +35,7 @@ type DialogParams = {
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "addPaymentMethod" | i18n }}
</span>
<div bitDialogContent>

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,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "cancelSubscription" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -11,7 +11,7 @@
<div class="tw-relative">
@if (isRecommended) {
<div
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-medium tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': plan().isSelected,
'tw-bg-secondary-100': !plan().isSelected,
@@ -28,12 +28,12 @@
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-medium tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span>
</h3>
<span>
<b class="tw-text-lg tw-font-semibold">{{ plan().costPerMember | currency: "$" }} </b>
<b class="tw-text-lg tw-font-medium">{{ plan().costPerMember | currency: "$" }} </b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
</span>
</div>

View File

@@ -1,7 +1,7 @@
<ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
<span class="tw-font-medium"
>{{ "total" | i18n }}:
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} USD</span
>
@@ -37,7 +37,7 @@
<ng-container
*ngIf="!summaryData.isSecretsManagerTrial || summaryData.organization.useSecretsManager"
>
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
<p class="tw-font-medium tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
<!-- Base Price -->
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.basePrice">
@@ -137,7 +137,7 @@
<!-- Secrets Manager section -->
<ng-template #secretsManagerSection>
<ng-container *ngIf="summaryData.organization.useSecretsManager">
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p>
<p class="tw-font-medium tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p>
<!-- Base Price -->
<ng-container *ngIf="summaryData.selectedPlan?.SecretsManager?.basePrice">
@@ -236,7 +236,7 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">{{ "estimatedTax" | i18n }}</span>
<span class="tw-font-medium">{{ "estimatedTax" | i18n }}</span>
<span>{{ summaryData.estimatedTax | currency: "USD" : "$" }}</span>
</p>
</bit-hint>
@@ -247,10 +247,10 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">{{ "total" | i18n }}</span>
<span class="tw-font-medium">{{ "total" | i18n }}</span>
<span>
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold"
<span class="tw-text-xs tw-font-medium"
>/ {{ summaryData.selectedPlanInterval | i18n }}</span
>
</span>

View File

@@ -1,5 +1,5 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "subscribetoEnterprise" | i18n: currentPlanName }}
</span>

View File

@@ -251,7 +251,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
this.loading = true;
let trialInitiationPath: InitiationPath = "Password Manager trial from marketing website";
let plan: PlanInformation = {
type: this.getPlanType(),
type: await this.getPlanType(),
passwordManagerSeats: 1,
};
@@ -293,14 +293,21 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
this.verticalStepper.previous();
}
getPlanType() {
async getPlanType() {
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
const familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
switch (this.productTier) {
case ProductTierType.Teams:
return PlanType.TeamsAnnually;
case ProductTierType.Enterprise:
return PlanType.EnterpriseAnnually;
case ProductTierType.Families:
return PlanType.FamiliesAnnually;
return familyPlan;
case ProductTierType.Free:
return PlanType.Free;
default:

View File

@@ -9,7 +9,7 @@
<li>
<p>
{{ "trialConfirmationEmail" | i18n }}
<span class="tw-font-bold">{{ email }}</span
<span class="tw-font-medium">{{ email }}</span
>.
</p>
</li>

View File

@@ -6,7 +6,7 @@
<div class="tw-container tw-mb-3">
<!-- Cadence -->
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<h2 class="tw-mb-3 tw-text-base tw-font-medium">{{ "billingPlanLabel" | i18n }}</h2>
<bit-radio-group [formControl]="formGroup.controls.cadence">
<div class="tw-mb-1 tw-items-center">
<bit-radio-button id="annual-cadence-button" [value]="'annually'">
@@ -32,7 +32,7 @@
</div>
<!-- Payment -->
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<h2 class="tw-mb-3 tw-text-base tw-font-medium">{{ "paymentType" | i18n }}</h2>
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
></app-enter-payment-method>

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, from, map, shareReplay } from "rxjs";
import { combineLatestWith, firstValueFrom, from, map, shareReplay } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
@@ -10,6 +10,8 @@ import {
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
@@ -62,6 +64,7 @@ export class TrialBillingStepService {
private apiService: ApiService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private taxClient: TaxClient,
private configService: ConfigService,
) {}
private plans$ = from(this.apiService.getPlans()).pipe(
@@ -70,10 +73,17 @@ export class TrialBillingStepService {
getPrices$ = (product: Product, tier: Tier) =>
this.plans$.pipe(
map((plans) => {
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
switch (tier) {
case "families": {
const annually = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually);
const annually = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025),
);
return {
annually: annually!.PasswordManager.basePrice,
};
@@ -149,9 +159,15 @@ export class TrialBillingStepService {
): Promise<OrganizationResponse> => {
const getPlanType = async (tier: Tier, cadence: Cadence) => {
const plans = await firstValueFrom(this.plans$);
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
const familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
switch (tier) {
case "families":
return plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!.type;
return plans.data.find((plan) => plan.type === familyPlan)!.type;
case "teams":
return plans.data.find(
(plan) =>

View File

@@ -11,7 +11,7 @@
[attr.aria-expanded]="selected"
>
<span
class="tw-mr-3.5 tw-w-9 tw-rounded-full tw-font-bold tw-leading-9"
class="tw-mr-3.5 tw-w-9 tw-rounded-full tw-font-medium tw-leading-9"
*ngIf="!step.completed"
[ngClass]="{
'tw-bg-primary-600 tw-text-contrast': selected,
@@ -22,7 +22,7 @@
{{ stepNumber }}
</span>
<span
class="tw-mr-3.5 tw-w-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
class="tw-mr-3.5 tw-w-9 tw-rounded-full tw-bg-primary-600 tw-font-medium tw-leading-9 tw-text-contrast"
*ngIf="step.completed"
>
<i class="bwi bwi-fw bwi-check tw-p-1" aria-hidden="true"></i>
@@ -30,7 +30,7 @@
<div
class="tw-txt-main tw-mt-3.5 tw-h-12 tw-text-left tw-leading-snug"
[ngClass]="{
'tw-font-bold': selected,
'tw-font-medium': selected,
}"
>
<p

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

@@ -9,11 +9,16 @@ import {
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
OrganizationUserService,
DefaultOrganizationUserService,
} from "@bitwarden/admin-console/common";
import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service";
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,
@@ -43,7 +48,10 @@ import {
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
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 {
InternalPolicyService,
@@ -55,6 +63,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
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";
@@ -78,6 +87,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { IpcService } from "@bitwarden/common/platform/ipc";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import {
@@ -94,6 +104,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,
@@ -106,10 +117,14 @@ import {
KeyService as KeyServiceAbstraction,
BiometricsService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import {
LockComponentService,
SessionTimeoutSettingsComponentService,
} from "@bitwarden/key-management-ui";
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service";
import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service";
import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service";
import { flagEnabled } from "../../utils/flags";
@@ -127,6 +142,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";
@@ -139,6 +155,7 @@ import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebSdkLoadService } from "../platform/web-sdk-load.service";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { WebSystemService } from "../platform/web-system.service";
import { EventService } from "./event.service";
import { InitService } from "./init.service";
@@ -332,6 +349,29 @@ const safeProviders: SafeProvider[] = [
OrganizationService,
],
}),
safeProvider({
provide: OrganizationUserService,
useClass: DefaultOrganizationUserService,
deps: [
KeyServiceAbstraction,
EncryptService,
OrganizationUserApiService,
AccountService,
I18nServiceAbstraction,
],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiService,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
],
}),
safeProvider({
provide: SdkLoadService,
useClass: flagEnabled("sdk") ? WebSdkLoadService : NoopSdkLoadService,
@@ -408,7 +448,31 @@ 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: SystemService,
useClass: WebSystemService,
deps: [],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: WebSessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, PlatformUtilsService],
}),
];

View File

@@ -12,17 +12,15 @@
</div>
</div>
<bit-card-content [ngClass]="{ 'tw-grayscale': disabled }">
<h3 class="tw-mb-4 tw-text-xl tw-font-bold">{{ title }}</h3>
<h3 class="tw-mb-4 tw-text-xl tw-font-medium">{{ 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

@@ -1,10 +1,9 @@
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
export class WebProcessReloadService implements ProcessReloadServiceAbstraction {
constructor(private window: Window) {}
async startProcessReload(authService: AuthService): Promise<void> {
async startProcessReload(): Promise<void> {
this.window.location.reload();
}

View File

@@ -0,0 +1,39 @@
import { defer, Observable, of } from "rxjs";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
export class WebSessionTimeoutSettingsComponentService
implements SessionTimeoutSettingsComponentService
{
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = defer(() => {
const options: VaultTimeoutOption[] = [
{ name: this.i18nService.t("oneMinute"), value: 1 },
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
{ name: this.i18nService.t("oneHour"), value: 60 },
{ name: this.i18nService.t("fourHours"), value: 240 },
{ name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart },
];
if (this.platformUtilsService.isDev()) {
options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never });
}
return of(options);
});
constructor(
private readonly i18nService: I18nService,
private readonly platformUtilsService: PlatformUtilsService,
) {}
onTimeoutSave(_: VaultTimeout): void {}
}

View File

@@ -0,0 +1,5 @@
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "sessionTimeoutHeader" | i18n }}</h2>
<div class="tw-max-w-lg">
<bit-session-timeout-settings />
</div>

View File

@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
@Component({
templateUrl: "session-timeout.component.html",
imports: [SessionTimeoutSettingsComponent, JslibModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionTimeoutComponent {}

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

@@ -13,17 +13,23 @@
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">
<bit-nav-item [text]="'myAccount' | i18n" route="settings/account"></bit-nav-item>
<bit-nav-item [text]="'security' | i18n" route="settings/security"></bit-nav-item>
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></bit-nav-item>
@if (consolidatedSessionTimeoutComponent$ | async) {
<bit-nav-item [text]="'appearance' | i18n" route="settings/appearance"></bit-nav-item>
} @else {
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></bit-nav-item>
}
<bit-nav-item
[text]="'subscription' | i18n"
route="settings/subscription"
*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,20 +38,47 @@ 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>;
protected consolidatedSessionTimeoutComponent$: Observable<boolean>;
constructor(
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;
}),
),
);
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
);
}
async ngOnInit() {

View File

@@ -14,6 +14,7 @@ import {
import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import {
DevicesIcon,
RegistrationUserAddIcon,
@@ -47,11 +48,15 @@ import {
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard";
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";
@@ -79,6 +84,7 @@ import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component";
import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component";
import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component";
import { AppearanceComponent } from "./settings/appearance.component";
import { DomainRulesComponent } from "./settings/domain-rules.component";
import { PreferencesComponent } from "./settings/preferences.component";
import { CredentialGeneratorComponent } from "./tools/credential-generator/credential-generator.component";
@@ -628,7 +634,7 @@ const routes: Routes = [
children: [
{
path: "vault",
canActivate: [setupExtensionRedirectGuard],
canActivate: [premiumInterestRedirectGuard, setupExtensionRedirectGuard],
loadChildren: () => VaultModule,
},
{
@@ -660,9 +666,30 @@ const routes: Routes = [
component: AccountComponent,
data: { titleId: "myAccount" } satisfies RouteDataProperties,
},
{
path: "appearance",
component: AppearanceComponent,
canActivate: [
canAccessFeature(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
true,
"/settings/preferences",
false,
),
],
data: { titleId: "appearance" } satisfies RouteDataProperties,
},
{
path: "preferences",
component: PreferencesComponent,
canActivate: [
canAccessFeature(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
false,
"/settings/appearance",
false,
),
],
data: { titleId: "preferences" } satisfies RouteDataProperties,
},
{
@@ -687,11 +714,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

@@ -0,0 +1,10 @@
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
/**
* Web implementation of SystemService.
* The implementation is NOOP since these functions are not supported on web.
*/
export class WebSystemService extends SystemService {
async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void> {}
async clearPendingClipboard(): Promise<any> {}
}

View File

@@ -0,0 +1,48 @@
<app-header></app-header>
<bit-container>
<form [formGroup]="form" class="tw-w-full tw-max-w-md">
<bit-form-field>
<bit-label>{{ "theme" | i18n }}</bit-label>
<bit-select formControlName="theme" id="theme">
@for (option of themeOptions; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
<bit-hint>{{ "themeDesc" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>
{{ "language" | i18n }}
<a
bitLink
class="tw-float-right"
href="https://bitwarden.com/help/localization/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutLocalization' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<bit-select formControlName="locale" id="locale">
@for (option of localeOptions; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
<bit-hint>{{ "languageDesc" | i18n }}</bit-hint>
</bit-form-field>
<div class="tw-flex tw-items-start tw-gap-1.5">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="enableFavicons" />
<bit-label>
{{ "showIconsChangePasswordUrls" | i18n }}
</bit-label>
</bit-form-control>
<div class="-tw-mt-0.5">
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</div>
</div>
</form>
</bit-container>

View File

@@ -0,0 +1,215 @@
import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { AppearanceComponent } from "./appearance.component";
describe("AppearanceComponent", () => {
let component: AppearanceComponent;
let fixture: ComponentFixture<AppearanceComponent>;
let mockI18nService: MockProxy<I18nService>;
let mockThemeStateService: MockProxy<ThemeStateService>;
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
const mockShowFavicons$ = new BehaviorSubject<boolean>(true);
const mockSelectedTheme$ = new BehaviorSubject<Theme>(ThemeTypes.Light);
const mockUserSetLocale$ = new BehaviorSubject<string | undefined>("en");
const mockSupportedLocales = ["en", "es", "fr", "de"];
const mockLocaleNames = new Map([
["en", "English"],
["es", "Español"],
["fr", "Français"],
["de", "Deutsch"],
]);
beforeEach(async () => {
mockI18nService = mock<I18nService>();
mockThemeStateService = mock<ThemeStateService>();
mockDomainSettingsService = mock<DomainSettingsService>();
mockI18nService.supportedTranslationLocales = mockSupportedLocales;
mockI18nService.localeNames = mockLocaleNames;
mockI18nService.collator = {
compare: jest.fn((a: string, b: string) => a.localeCompare(b)),
} as any;
mockI18nService.t.mockImplementation((key: string) => `${key}-used-i18n`);
mockI18nService.userSetLocale$ = mockUserSetLocale$;
mockThemeStateService.selectedTheme$ = mockSelectedTheme$;
mockDomainSettingsService.showFavicons$ = mockShowFavicons$;
mockDomainSettingsService.setShowFavicons.mockResolvedValue(undefined);
mockThemeStateService.setSelectedTheme.mockResolvedValue(undefined);
mockI18nService.setLocale.mockResolvedValue(undefined);
await TestBed.configureTestingModule({
imports: [AppearanceComponent, ReactiveFormsModule, NoopAnimationsModule],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: ThemeStateService, useValue: mockThemeStateService },
{ provide: DomainSettingsService, useValue: mockDomainSettingsService },
],
})
.overrideComponent(AppearanceComponent, {
set: {
template: "",
imports: [],
},
})
.compileComponents();
fixture = TestBed.createComponent(AppearanceComponent);
component = fixture.componentInstance;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("constructor", () => {
describe("locale options setup", () => {
it("should create locale options sorted by name from supported locales with display names", () => {
expect(component.localeOptions).toHaveLength(5);
expect(component.localeOptions[0]).toEqual({ name: "default-used-i18n", value: null });
expect(component.localeOptions[1]).toEqual({ name: "de - Deutsch", value: "de" });
expect(component.localeOptions[2]).toEqual({ name: "en - English", value: "en" });
expect(component.localeOptions[3]).toEqual({ name: "es - Español", value: "es" });
expect(component.localeOptions[4]).toEqual({ name: "fr - Français", value: "fr" });
});
});
describe("theme options setup", () => {
it("should create theme options with Light, Dark, and System", () => {
expect(component.themeOptions).toEqual([
{ name: "themeLight-used-i18n", value: ThemeTypes.Light },
{ name: "themeDark-used-i18n", value: ThemeTypes.Dark },
{ name: "themeSystem-used-i18n", value: ThemeTypes.System },
]);
});
});
});
describe("ngOnInit", () => {
it("should initialize form with values", fakeAsync(() => {
mockShowFavicons$.next(false);
mockSelectedTheme$.next(ThemeTypes.Dark);
mockUserSetLocale$.next("es");
fixture.detectChanges();
flush();
expect(component.form.value).toEqual({
enableFavicons: false,
theme: ThemeTypes.Dark,
locale: "es",
});
}));
it("should set locale to null when user locale not set", fakeAsync(() => {
mockUserSetLocale$.next(undefined);
fixture.detectChanges();
flush();
expect(component.form.value.locale).toBeNull();
}));
});
describe("enableFavicons value changes", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
jest.clearAllMocks();
}));
it("should call setShowFavicons when enableFavicons changes to true", fakeAsync(() => {
component.form.controls.enableFavicons.setValue(true);
flush();
expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(true);
}));
it("should call setShowFavicons when enableFavicons changes to false", fakeAsync(() => {
component.form.controls.enableFavicons.setValue(false);
flush();
expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(false);
}));
it("should not call setShowFavicons when value is null", fakeAsync(() => {
component.form.controls.enableFavicons.setValue(null);
flush();
expect(mockDomainSettingsService.setShowFavicons).not.toHaveBeenCalled();
}));
});
describe("theme value changes", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
jest.clearAllMocks();
}));
it.each([ThemeTypes.Light, ThemeTypes.Dark, ThemeTypes.System])(
"should call setSelectedTheme when theme changes to %s",
fakeAsync((themeType: Theme) => {
component.form.controls.theme.setValue(themeType);
flush();
expect(mockThemeStateService.setSelectedTheme).toHaveBeenCalledWith(themeType);
}),
);
it("should not call setSelectedTheme when value is null", fakeAsync(() => {
component.form.controls.theme.setValue(null);
flush();
expect(mockThemeStateService.setSelectedTheme).not.toHaveBeenCalled();
}));
});
describe("locale value changes", () => {
let reloadMock: jest.Mock;
beforeEach(fakeAsync(() => {
reloadMock = jest.fn();
Object.defineProperty(window, "location", {
value: { reload: reloadMock },
writable: true,
});
fixture.detectChanges();
flush();
jest.clearAllMocks();
}));
it("should call setLocale and reload window when locale changes to english", fakeAsync(() => {
component.form.controls.locale.setValue("es");
flush();
expect(mockI18nService.setLocale).toHaveBeenCalledWith("es");
expect(reloadMock).toHaveBeenCalled();
}));
it("should call setLocale and reload window when locale changes to default", fakeAsync(() => {
component.form.controls.locale.setValue(null);
flush();
expect(mockI18nService.setLocale).toHaveBeenCalledWith(null);
expect(reloadMock).toHaveBeenCalled();
}));
});
});

View File

@@ -0,0 +1,107 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder } from "@angular/forms";
import { filter, firstValueFrom, switchMap } from "rxjs";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { HeaderModule } from "../layouts/header/header.module";
import { SharedModule } from "../shared";
type LocaleOption = {
name: string;
value: string | null;
};
type ThemeOption = {
name: string;
value: Theme;
};
@Component({
selector: "app-appearance",
templateUrl: "appearance.component.html",
imports: [SharedModule, HeaderModule, PermitCipherDetailsPopoverComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppearanceComponent implements OnInit {
localeOptions: LocaleOption[];
themeOptions: ThemeOption[];
form = this.formBuilder.group({
enableFavicons: true,
theme: [ThemeTypes.Light as Theme],
locale: [null as string | null],
});
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
private themeStateService: ThemeStateService,
private domainSettingsService: DomainSettingsService,
private destroyRef: DestroyRef,
) {
const localeOptions: LocaleOption[] = [];
i18nService.supportedTranslationLocales.forEach((locale) => {
let name = locale;
if (i18nService.localeNames.has(locale)) {
name += " - " + i18nService.localeNames.get(locale);
}
localeOptions.push({ name: name, value: locale });
});
localeOptions.sort(Utils.getSortFunction(i18nService, "name"));
localeOptions.splice(0, 0, { name: i18nService.t("default"), value: null });
this.localeOptions = localeOptions;
this.themeOptions = [
{ name: i18nService.t("themeLight"), value: ThemeTypes.Light },
{ name: i18nService.t("themeDark"), value: ThemeTypes.Dark },
{ name: i18nService.t("themeSystem"), value: ThemeTypes.System },
];
}
async ngOnInit() {
this.form.setValue(
{
enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: (await firstValueFrom(this.i18nService.userSetLocale$)) ?? null,
},
{ emitEvent: false },
);
this.form.controls.enableFavicons.valueChanges
.pipe(
filter((enableFavicons) => enableFavicons != null),
switchMap(async (enableFavicons) => {
await this.domainSettingsService.setShowFavicons(enableFavicons);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
this.form.controls.theme.valueChanges
.pipe(
filter((theme) => theme != null),
switchMap(async (theme) => {
await this.themeStateService.setSelectedTheme(theme);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
this.form.controls.locale.valueChanges
.pipe(
switchMap(async (locale) => {
await this.i18nService.setLocale(locale);
window.location.reload();
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
}

Some files were not shown because too many files have changed in this diff Show More