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:
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -74,7 +74,6 @@ export class vNextOrganizationDataOwnershipPolicyComponent
|
||||
|
||||
const request: VNextPolicyRequest = {
|
||||
policy: {
|
||||
type: this.policy.type,
|
||||
enabled: this.enabled.value ?? false,
|
||||
data: this.buildRequestData(),
|
||||
},
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>() },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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">
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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">
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<bit-dialog dialogSize="default">
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
<span bitDialogTitle class="tw-font-medium">
|
||||
{{ "subscribetoEnterprise" | i18n: currentPlanName }}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<li>
|
||||
<p>
|
||||
{{ "trialConfirmationEmail" | i18n }}
|
||||
<span class="tw-font-bold">{{ email }}</span
|
||||
<span class="tw-font-medium">{{ email }}</span
|
||||
>.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,4 +37,8 @@ export class ReportCardComponent {
|
||||
protected get requiresPremium() {
|
||||
return this.variant == ReportVariant.RequiresPremium;
|
||||
}
|
||||
|
||||
protected get requiresUpgrade() {
|
||||
return this.variant == ReportVariant.RequiresUpgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
10
apps/web/src/app/platform/web-system.service.ts
Normal file
10
apps/web/src/app/platform/web-system.service.ts
Normal 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> {}
|
||||
}
|
||||
48
apps/web/src/app/settings/appearance.component.html
Normal file
48
apps/web/src/app/settings/appearance.component.html
Normal 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>
|
||||
215
apps/web/src/app/settings/appearance.component.spec.ts
Normal file
215
apps/web/src/app/settings/appearance.component.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
107
apps/web/src/app/settings/appearance.component.ts
Normal file
107
apps/web/src/app/settings/appearance.component.ts
Normal 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
Reference in New Issue
Block a user