1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 01:23:24 +00:00

Merge branch 'main' into auth/add-logout-reason

This commit is contained in:
Todd Martin
2026-01-11 11:24:18 -05:00
1569 changed files with 125258 additions and 31664 deletions

View File

@@ -0,0 +1,130 @@
import { TestBed } from "@angular/core/testing";
import { ReplaySubject } from "rxjs";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
Environment,
EnvironmentService,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { PeopleTableDataSource } from "./people-table-data-source";
interface MockUser {
id: string;
name: string;
email: string;
status: OrganizationUserStatusType;
checked?: boolean;
}
class TestPeopleTableDataSource extends PeopleTableDataSource<any> {
protected statusType = OrganizationUserStatusType;
}
describe("PeopleTableDataSource", () => {
let dataSource: TestPeopleTableDataSource;
const createMockUser = (id: string, checked: boolean = false): MockUser => ({
id,
name: `User ${id}`,
email: `user${id}@example.com`,
status: OrganizationUserStatusType.Confirmed,
checked,
});
const createMockUsers = (count: number, checked: boolean = false): MockUser[] => {
return Array.from({ length: count }, (_, i) => createMockUser(`${i + 1}`, checked));
};
beforeEach(() => {
const featureFlagSubject = new ReplaySubject<boolean>(1);
featureFlagSubject.next(false);
const environmentSubject = new ReplaySubject<Environment>(1);
environmentSubject.next({
isCloud: () => false,
} as Environment);
const mockConfigService = {
getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()),
} as any;
const mockEnvironmentService = {
environment$: environmentSubject.asObservable(),
} as any;
TestBed.configureTestingModule({
providers: [
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
],
});
dataSource = TestBed.runInInjectionContext(
() => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService),
);
});
describe("limitAndUncheckExcess", () => {
it("should return all users when under limit", () => {
const users = createMockUsers(10, true);
dataSource.data = users;
const result = dataSource.limitAndUncheckExcess(users, 500);
expect(result).toHaveLength(10);
expect(result).toEqual(users);
expect(users.every((u) => u.checked)).toBe(true);
});
it("should limit users and uncheck excess", () => {
const users = createMockUsers(600, true);
dataSource.data = users;
const result = dataSource.limitAndUncheckExcess(users, 500);
expect(result).toHaveLength(500);
expect(result).toEqual(users.slice(0, 500));
expect(users.slice(0, 500).every((u) => u.checked)).toBe(true);
expect(users.slice(500).every((u) => u.checked)).toBe(false);
});
it("should only affect users in the provided array", () => {
const allUsers = createMockUsers(1000, true);
dataSource.data = allUsers;
// Pass only a subset (simulates filtering by status)
const subset = allUsers.slice(0, 600);
const result = dataSource.limitAndUncheckExcess(subset, 500);
expect(result).toHaveLength(500);
expect(subset.slice(0, 500).every((u) => u.checked)).toBe(true);
expect(subset.slice(500).every((u) => u.checked)).toBe(false);
// Users outside subset remain checked
expect(allUsers.slice(600).every((u) => u.checked)).toBe(true);
});
});
describe("status counts", () => {
it("should correctly count users by status", () => {
const users: MockUser[] = [
{ ...createMockUser("1"), status: OrganizationUserStatusType.Invited },
{ ...createMockUser("2"), status: OrganizationUserStatusType.Invited },
{ ...createMockUser("3"), status: OrganizationUserStatusType.Accepted },
{ ...createMockUser("4"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("5"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("6"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("7"), status: OrganizationUserStatusType.Revoked },
];
dataSource.data = users;
expect(dataSource.invitedUserCount).toBe(2);
expect(dataSource.acceptedUserCount).toBe(1);
expect(dataSource.confirmedUserCount).toBe(3);
expect(dataSource.revokedUserCount).toBe(1);
expect(dataSource.activeUserCount).toBe(6); // All except revoked
});
});
});

View File

@@ -1,14 +1,30 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { computed, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
import {
OrganizationUserStatusType,
ProviderUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { TableDataSource } from "@bitwarden/components";
import { StatusType, UserViewTypes } from "./base-members.component";
const MaxCheckedCount = 500;
/**
* Default maximum for most bulk operations (confirm, remove, delete, etc.)
*/
export const MaxCheckedCount = 500;
/**
* Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud
* feature flag is enabled on cloud environments.
*/
export const CloudBulkReinviteLimit = 8000;
/**
* Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked).
@@ -56,6 +72,20 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
confirmedUserCount: number;
revokedUserCount: number;
/** True when increased bulk limit feature is enabled (feature flag + cloud environment) */
readonly isIncreasedBulkLimitEnabled: Signal<boolean>;
constructor(configService: ConfigService, environmentService: EnvironmentService) {
super();
const featureFlagEnabled = toSignal(
configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud())));
this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud());
}
override set data(data: T[]) {
super.data = data;
@@ -89,6 +119,14 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
return this.data.filter((u) => (u as any).checked);
}
/**
* Gets checked users in the order they appear in the filtered/sorted table view.
* Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked).
*/
getCheckedUsersInVisibleOrder() {
return this.filteredData.filter((u) => (u as any).checked);
}
/**
* Check all filtered users (i.e. those rows that are currently visible)
* @param select check the filtered users (true) or uncheck the filtered users (false)
@@ -101,8 +139,13 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
const filteredUsers = this.filteredData;
const selectCount =
filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length;
// When the increased bulk limit feature is enabled, allow checking all users.
// Individual bulk operations will enforce their specific limits.
// When disabled, enforce the legacy limit at check time.
const selectCount = this.isIncreasedBulkLimitEnabled()
? filteredUsers.length
: Math.min(filteredUsers.length, MaxCheckedCount);
for (let i = 0; i < selectCount; i++) {
this.checkUser(filteredUsers[i], select);
}
@@ -132,4 +175,41 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
this.data = updatedData;
}
}
/**
* Limits an array of users and unchecks those beyond the limit.
* Returns the limited array.
*
* @param users The array of users to limit
* @param limit The maximum number of users to keep
* @returns The users array limited to the specified count
*/
limitAndUncheckExcess(users: T[], limit: number): T[] {
if (users.length <= limit) {
return users;
}
// Uncheck users beyond the limit
users.slice(limit).forEach((user) => this.checkUser(user, false));
return users.slice(0, limit);
}
/**
* Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag.
*
* When the feature flag is enabled: Returns checked users in visible order, limited to the specified count.
* When the feature flag is disabled: Returns all checked users without applying any limit.
*
* @param limit The maximum number of users to return (only applied when feature flag is enabled)
* @returns The checked users array
*/
getCheckedUsersWithLimit(limit: number): T[] {
if (this.isIncreasedBulkLimitEnabled()) {
const allUsers = this.getCheckedUsersInVisibleOrder();
return this.limitAndUncheckExcess(allUsers, limit);
} else {
return this.getCheckedUsers();
}
}
}

View File

@@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -59,6 +60,7 @@ export class VaultFilterComponent
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(
vaultFilterService,
@@ -72,6 +74,7 @@ export class VaultFilterComponent
restrictedItemTypesService,
cipherService,
cipherArchiveService,
premiumUpgradePromptService,
);
}

View File

@@ -5,7 +5,6 @@ import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -35,7 +34,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider: StateProvider,
collectionService: CollectionService,
accountService: AccountService,
configService: ConfigService,
) {
super(
organizationService,
@@ -46,7 +44,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider,
collectionService,
accountService,
configService,
);
}

View File

@@ -587,6 +587,9 @@ export class VaultComponent implements OnInit, OnDestroy {
queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText },
queryParamsHandling: "merge",
replaceUrl: true,
state: {
focusMainAfterNav: false,
},
}),
);

View File

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

View File

@@ -2,12 +2,15 @@
<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-item
icon="bwi-dashboard"
*ngIf="organization.useAccessIntelligence && organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
@if (canShowAccessIntelligenceTab(organization)) {
<bit-nav-item
icon="bwi-dashboard"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
}
<bit-nav-item
icon="bwi-collection-shared"
[text]="'collections' | i18n"
@@ -101,12 +104,12 @@
*ngIf="organization.use2fa && organization.isOwner"
></bit-nav-item>
<bit-nav-item
[text]="'importData' | i18n"
[text]="'importNoun' | i18n"
route="settings/tools/import"
*ngIf="organization.canAccessImport"
></bit-nav-item>
<bit-nav-item
[text]="'exportVault' | i18n"
[text]="'exportNoun' | i18n"
route="settings/tools/export"
*ngIf="canAccessExport$ | async"
></bit-nav-item>

View File

@@ -8,6 +8,7 @@ import { combineLatest, filter, map, Observable, switchMap, withLatestFrom } fro
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AdminConsoleLogo } from "@bitwarden/assets/svg";
import {
canAccessAccessIntelligence,
canAccessBillingTab,
canAccessGroupsTab,
canAccessMembersTab,
@@ -172,6 +173,10 @@ export class OrganizationLayoutComponent implements OnInit {
return canAccessBillingTab(organization);
}
canShowAccessIntelligenceTab(organization: Organization): boolean {
return canAccessAccessIntelligence(organization);
}
getReportTabLabel(organization: Organization): string {
return organization.useEvents ? "reporting" : "reports";
}

View File

@@ -6,7 +6,6 @@ import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkConfirmRequest,
OrganizationUserBulkPublicKeyResponse,
OrganizationUserBulkResponse,
OrganizationUserService,
@@ -15,10 +14,8 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -54,7 +51,6 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);
@@ -84,19 +80,9 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected postConfirmRequest = async (
userIdsWithKeys: { id: string; key: string }[],
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
} else {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organization.id,
request,
);
}
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
};
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {

View File

@@ -1 +1,2 @@
export * from "./members.module";
export * from "./pipes";

View File

@@ -102,15 +102,25 @@
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
@@ -352,13 +362,16 @@
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<div class="tw-w-[32px]"></div>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">

View File

@@ -33,6 +33,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -44,12 +47,20 @@ import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/membe
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
PeopleTableDataSource,
} from "../../common/people-table-data-source";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import { MemberDialogManagerService, OrganizationMembersService } from "./services";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
@@ -70,7 +81,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
protected dataSource = new MembersTableDataSource();
protected dataSource: MembersTableDataSource;
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
@@ -113,6 +124,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private fileDownloadService: FileDownloadService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
@@ -126,6 +141,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
const organization$ = this.route.params.pipe(
concatMap((params) =>
this.userId$.pipe(
@@ -356,10 +373,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkRemoveDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
this.organizationMetadataService.refreshMetadataCache();
await this.load(organization);
}
@@ -369,10 +385,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkDeleteDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
await this.load(organization);
}
@@ -389,11 +404,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkRestoreRevokeDialog(
organization,
this.dataSource.getCheckedUsers(),
isRevoking,
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
await this.load(organization);
}
@@ -402,8 +415,28 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
const users = this.dataSource.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
let users: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
}
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource.limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
} else {
filteredUsers = allInvitedUsers;
}
if (filteredUsers.length <= 0) {
this.toastService.showToast({
@@ -417,20 +450,44 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
try {
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id),
filteredUsers.map((user) => user.id as UserId),
);
if (!result.successful) {
throw new Error();
}
// Bulk Status component open
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -442,15 +499,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkConfirmDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsers();
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
@@ -544,4 +600,36 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
exportMembers = async (): Promise<void> => {
try {
const members = this.dataSource.data;
if (!members || members.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noMembersToExport"),
});
return;
}
const csvData = this.memberExportService.getMemberExport(members);
const fileName = this.memberExportService.getFileName("org-members");
this.fileDownloadService.download({
fileName: fileName,
blobData: csvData,
blobOptions: { type: "text/plain" },
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("dataExportSuccess"),
});
} catch (e) {
this.validationService.showError(e);
this.logService.error(`Failed to export members: ${e}`);
}
};
}

View File

@@ -19,10 +19,12 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import { UserStatusPipe } from "./pipes";
import {
OrganizationMembersService,
MemberActionsService,
MemberDialogManagerService,
MemberExportService,
} from "./services";
@NgModule({
@@ -45,12 +47,15 @@ import {
BulkStatusComponent,
MembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
MemberExportService,
UserStatusPipe,
],
})
export class MembersModule {}

View File

@@ -0,0 +1 @@
export * from "./user-status.pipe";

View File

@@ -0,0 +1,47 @@
import { MockProxy, mock } from "jest-mock-extended";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserStatusPipe } from "./user-status.pipe";
describe("UserStatusPipe", () => {
let pipe: UserStatusPipe;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
pipe = new UserStatusPipe(i18nService);
});
it("transforms OrganizationUserStatusType.Invited to 'invited'", () => {
expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited");
expect(i18nService.t).toHaveBeenCalledWith("invited");
});
it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => {
expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted");
expect(i18nService.t).toHaveBeenCalledWith("accepted");
});
it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => {
expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed");
expect(i18nService.t).toHaveBeenCalledWith("confirmed");
});
it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => {
expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked");
expect(i18nService.t).toHaveBeenCalledWith("revoked");
});
it("transforms null to 'unknown'", () => {
expect(pipe.transform(null)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
it("transforms undefined to 'unknown'", () => {
expect(pipe.transform(undefined)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
});

View File

@@ -0,0 +1,30 @@
import { Pipe, PipeTransform } from "@angular/core";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Pipe({
name: "userStatus",
standalone: false,
})
export class UserStatusPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserStatusType): string {
if (value == null) {
return this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserStatusType.Invited:
return this.i18nService.t("invited");
case OrganizationUserStatusType.Accepted:
return this.i18nService.t("accepted");
case OrganizationUserStatusType.Confirmed:
return this.i18nService.t("confirmed");
case OrganizationUserStatusType.Revoked:
return this.i18nService.t("revoked");
default:
return this.i18nService.t("unknown");
}
}
}

View File

@@ -1,4 +1,5 @@
export { OrganizationMembersService } from "./organization-members-service/organization-members.service";
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { MemberExportService } from "./member-export";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";

View File

@@ -4,6 +4,7 @@ import { of } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import {
OrganizationUserType,
@@ -11,32 +12,22 @@ import {
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
import { MemberActionsService } from "./member-actions.service";
import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service";
describe("MemberActionsService", () => {
let service: MemberActionsService;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId;
const userIdToManage = newGuid();
@@ -46,10 +37,7 @@ describe("MemberActionsService", () => {
beforeEach(() => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationUserService = mock<OrganizationUserService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganization = {
@@ -71,10 +59,7 @@ describe("MemberActionsService", () => {
service = new MemberActionsService(
organizationUserApiService,
organizationUserService,
keyService,
encryptService,
configService,
accountService,
organizationMetadataService,
);
});
@@ -242,8 +227,7 @@ describe("MemberActionsService", () => {
describe("confirmUser", () => {
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
it("should confirm user using new flow when feature flag is enabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
it("should confirm user", async () => {
organizationUserService.confirmUser.mockReturnValue(of(undefined));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
@@ -257,44 +241,7 @@ describe("MemberActionsService", () => {
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should confirm user using exising flow when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const mockOrgKey = mock<OrgKey>();
const mockOrgKeys = { [organizationId]: mockOrgKey };
keyService.orgKeys$.mockReturnValue(of(mockOrgKeys));
const mockEncryptedKey = new EncString("encrypted-key-data");
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(keyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
organizationId,
userIdToManage,
expect.objectContaining({
key: "encrypted-key-data",
}),
);
});
it("should handle missing organization keys", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
keyService.orgKeys$.mockReturnValue(of({}));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain("Organization keys not found");
});
it("should handle confirm errors", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const errorMessage = "Confirm failed";
organizationUserService.confirmUser.mockImplementation(() => {
throw new Error(errorMessage);
@@ -308,41 +255,308 @@ describe("MemberActionsService", () => {
});
describe("bulkReinvite", () => {
const userIds = [newGuid(), newGuid(), newGuid()];
const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId];
it("should successfully reinvite multiple users", async () => {
const mockResponse = {
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
} as ListResponse<OrganizationUserBulkResponse>;
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result).toEqual({
successful: mockResponse,
failed: [],
describe("when feature flag is false", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it("should successfully reinvite multiple users", async () => {
const mockResponse = new ListResponse(
{
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.failed).toEqual([]);
expect(result.successful).toBeDefined();
expect(result.successful).toEqual(mockResponse);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
});
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
describe("when feature flag is true (batching behavior)", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
});
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
const mockResponse = new ListResponse(
{
data: userIdsBatch.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIds);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
1,
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIdsBatch,
);
});
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
1,
organizationId,
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
2,
organizationId,
userIdsBatch.slice(REQUESTS_PER_BATCH),
);
});
it("should aggregate results across multiple successful batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(
mockResponse1.data,
);
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
expect(result.failed).toHaveLength(0);
});
it("should handle mixed individual errors across multiple batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 4;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
id,
error: index % 10 === 0 ? "Rate limit exceeded" : null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const mockResponse2 = new ListResponse(
{
data: [
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
],
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
const expectedFailuresInBatch2 = 2;
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
const expectedSuccesses = totalUsers - expectedTotalFailures;
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(expectedSuccesses);
expect(result.failed).toHaveLength(expectedTotalFailures);
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
});
it("should aggregate all failures when all batches fail", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const errorMessage = "All batches failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(totalUsers);
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
});
it("should handle empty data in batch response", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const mockResponse2 = new ListResponse(
{
data: [],
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
});
it("should process batches sequentially in order", async () => {
const totalUsers = REQUESTS_PER_BATCH * 2;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const callOrder: number[] = [];
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
async (orgId, ids) => {
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
callOrder.push(batchIndex);
return new ListResponse(
{
data: ids.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
},
);
await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(callOrder).toEqual([1, 2]);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
});
});
});
@@ -427,14 +641,6 @@ describe("MemberActionsService", () => {
expect(result).toBe(false);
});
it("should not allow reset password when organization lacks public and private keys", () => {
const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when user is not enrolled in reset password", () => {
const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView;
@@ -443,12 +649,6 @@ describe("MemberActionsService", () => {
expect(result).toBe(false);
});
it("should not allow reset password when reset password is disabled", () => {
const result = service.allowResetPassword(mockOrgUser, mockOrganization, false);
expect(result).toBe(false);
});
it("should not allow reset password when user status is not confirmed", () => {
const user = {
...mockOrgUser,

View File

@@ -1,10 +1,9 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserConfirmRequest,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import {
@@ -12,17 +11,16 @@ import {
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
export const REQUESTS_PER_BATCH = 500;
export interface MemberActionResult {
success: boolean;
error?: string;
@@ -35,15 +33,10 @@ export interface BulkActionResult {
@Injectable()
export class MemberActionsService {
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private keyService: KeyService,
private encryptService: EncryptService,
private configService: ConfigService,
private accountService: AccountService,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {}
@@ -125,57 +118,45 @@ export class MemberActionsService {
organization: Organization,
): Promise<MemberActionResult> {
try {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
} else {
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
await this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async bulkReinvite(organization: Organization, userIds: string[]): Promise<BulkActionResult> {
try {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
if (increaseBulkReinviteLimitForCloud) {
return await this.vNextBulkReinvite(organization, userIds);
} else {
try {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
}
}
}
async vNextBulkReinvite(
organization: Organization,
userIds: UserId[],
): Promise<BulkActionResult> {
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
);
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
@@ -207,4 +188,52 @@ export class MemberActionsService {
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
/**
* Processes user IDs in sequential batches and aggregates results.
* @param userIds - Array of user IDs to process
* @param batchSize - Number of IDs to process per batch
* @param processBatch - Async function that processes a single batch and returns the result
* @returns Aggregated bulk action result
*/
private async processBatchedOperation(
userIds: UserId[],
batchSize: number,
processBatch: (batch: string[]) => Promise<ListResponse<OrganizationUserBulkResponse>>,
): Promise<BulkActionResult> {
const allSuccessful: OrganizationUserBulkResponse[] = [];
const allFailed: { id: string; error: string }[] = [];
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
try {
const result = await processBatch(batch);
if (result?.data) {
for (const response of result.data) {
if (response.error) {
allFailed.push({ id: response.id, error: response.error });
} else {
allSuccessful.push(response);
}
}
}
} catch (error) {
allFailed.push(
...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
);
}
}
const successful =
allSuccessful.length > 0
? new ListResponse(allSuccessful, OrganizationUserBulkResponse)
: undefined;
return {
successful,
failed: allFailed,
};
}
}

View File

@@ -0,0 +1,2 @@
export * from "./member.export";
export * from "./member-export.service";

View File

@@ -0,0 +1,151 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExportService } from "./member-export.service";
describe("MemberExportService", () => {
let service: MemberExportService;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
// Setup common i18n translations
i18nService.t.mockImplementation((key: string) => {
const translations: Record<string, string> = {
// Column headers
email: "Email",
name: "Name",
status: "Status",
role: "Role",
twoStepLogin: "Two-step Login",
accountRecovery: "Account Recovery",
secretsManager: "Secrets Manager",
groups: "Groups",
// Status values
invited: "Invited",
accepted: "Accepted",
confirmed: "Confirmed",
revoked: "Revoked",
// Role values
owner: "Owner",
admin: "Admin",
user: "User",
custom: "Custom",
// Boolean states
enabled: "Enabled",
disabled: "Disabled",
enrolled: "Enrolled",
notEnrolled: "Not Enrolled",
};
return translations[key] || key;
});
TestBed.configureTestingModule({
providers: [
MemberExportService,
{ provide: I18nService, useValue: i18nService },
UserTypePipe,
UserStatusPipe,
],
});
service = TestBed.inject(MemberExportService);
});
describe("getMemberExport", () => {
it("should export members with all fields populated", () => {
const members: OrganizationUserView[] = [
{
email: "user1@example.com",
name: "User One",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
twoFactorEnabled: true,
resetPasswordEnrolled: true,
accessSecretsManager: true,
groupNames: ["Group A", "Group B"],
} as OrganizationUserView,
{
email: "user2@example.com",
name: "User Two",
status: OrganizationUserStatusType.Invited,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: ["Group C"],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
expect(csvData).toContain("user1@example.com");
expect(csvData).toContain("User One");
expect(csvData).toContain("Confirmed");
expect(csvData).toContain("Admin");
expect(csvData).toContain("user2@example.com");
expect(csvData).toContain("User Two");
expect(csvData).toContain("Invited");
});
it("should handle members with null name", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: null,
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: [],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
// Empty name is represented as an empty field in CSV
expect(csvData).toContain("user@example.com,,Confirmed");
});
it("should handle members with no groups", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: "User",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: null,
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
expect(csvData).toBeDefined();
});
it("should handle empty members array", () => {
const csvData = service.getMemberExport([]);
// When array is empty, papaparse returns an empty string
expect(csvData).toBe("");
});
});
});

View File

@@ -0,0 +1,49 @@
import { inject, Injectable } from "@angular/core";
import * as papa from "papaparse";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExport } from "./member.export";
@Injectable()
export class MemberExportService {
private i18nService = inject(I18nService);
private userTypePipe = inject(UserTypePipe);
private userStatusPipe = inject(UserStatusPipe);
getMemberExport(members: OrganizationUserView[]): string {
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
const headers: string[] = [
this.i18nService.t("email"),
this.i18nService.t("name"),
this.i18nService.t("status"),
this.i18nService.t("role"),
this.i18nService.t("twoStepLogin"),
this.i18nService.t("accountRecovery"),
this.i18nService.t("secretsManager"),
this.i18nService.t("groups"),
];
return papa.unparse(exportData, {
columns: headers,
header: true,
});
}
getFileName(prefix: string | null = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -0,0 +1,43 @@
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
export class MemberExport {
/**
* @param user Organization user to export
* @returns a Record<string, string> of each column header key, value
* All property members must be a string for export purposes. Null and undefined will appear as
* "null" in a .csv export, therefore an empty string is preferable to a nullish type.
*/
static fromOrganizationUserView(
i18nService: I18nService,
userTypePipe: UserTypePipe,
userStatusPipe: UserStatusPipe,
user: OrganizationUserView,
): Record<string, string> {
const result = {
[i18nService.t("email")]: user.email,
[i18nService.t("name")]: user.name ?? "",
[i18nService.t("status")]: userStatusPipe.transform(user.status),
[i18nService.t("role")]: userTypePipe.transform(user.type),
[i18nService.t("twoStepLogin")]: user.twoFactorEnabled
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("accountRecovery")]: user.resetPasswordEnrolled
? i18nService.t("enrolled")
: i18nService.t("notEnrolled"),
[i18nService.t("secretsManager")]: user.accessSecretsManager
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("groups")]: user.groupNames?.join(", ") ?? "",
};
return result;
}
}

View File

@@ -35,13 +35,10 @@ import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-pa
@Injectable({
providedIn: "root",
})
export class OrganizationUserResetPasswordService
implements
UserKeyRotationKeyRecoveryProvider<
OrganizationUserResetPasswordWithIdRequest,
OrganizationUserResetPasswordEntry
>
{
export class OrganizationUserResetPasswordService implements UserKeyRotationKeyRecoveryProvider<
OrganizationUserResetPasswordWithIdRequest,
OrganizationUserResetPasswordEntry
> {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,

View File

@@ -22,7 +22,7 @@ import {
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
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";
@@ -30,7 +30,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
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 { getById } from "@bitwarden/common/platform/misc";
import {
@@ -115,7 +114,6 @@ export class AutoConfirmPolicyDialogComponent
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
@@ -131,7 +129,6 @@ export class AutoConfirmPolicyDialogComponent
formBuilder,
dialogRef,
toastService,
configService,
keyService,
);

View File

@@ -1,27 +1,34 @@
<app-header></app-header>
@let organization = organization$ | async;
@let policiesEnabledMap = policiesEnabledMap$ | async;
@let organizationId = organizationId$ | async;
<bit-container>
@if (loading) {
@if (!organization || !policiesEnabledMap || !organizationId) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
}
@if (!loading) {
} @else {
<bit-table>
<ng-template body>
@for (p of policies$ | async; track p.type) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
@for (p of policies$ | async; track $index) {
@if (p.display$(organization, configService) | async) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p, organizationId)">
{{ p.name | i18n }}
</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
}
}
</ng-template>
</bit-table>

View File

@@ -0,0 +1,548 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of, firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import { PoliciesComponent } from "./policies.component";
import { SingleOrgPolicy } from "./policy-edit-definitions/single-org.component";
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
describe("PoliciesComponent", () => {
let component: PoliciesComponent;
let fixture: ComponentFixture<PoliciesComponent>;
let mockActivatedRoute: ActivatedRoute;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockAccountService: FakeAccountService;
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
let mockPolicyListService: MockProxy<PolicyListService>;
let mockDialogService: MockProxy<DialogService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockConfigService: MockProxy<ConfigService>;
let mockI18nService: MockProxy<I18nService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let routeParamsSubject: BehaviorSubject<any>;
let queryParamsSubject: BehaviorSubject<any>;
const mockUserId = newGuid() as UserId;
const mockOrgId = newGuid() as OrganizationId;
const mockOrg = {
id: mockOrgId,
name: "Test Organization",
enabled: true,
} as Organization;
const mockPolicyResponse = {
id: newGuid(),
enabled: true,
object: "policy",
organizationId: mockOrgId,
type: PolicyType.SingleOrg,
data: null,
};
const mockPolicy = new SingleOrgPolicy();
beforeEach(async () => {
routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId });
queryParamsSubject = new BehaviorSubject({});
mockActivatedRoute = {
params: routeParamsSubject.asObservable(),
queryParams: queryParamsSubject.asObservable(),
} as any;
mockOrganizationService = mock<OrganizationService>();
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
mockAccountService = mockAccountServiceWith(mockUserId);
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [mockPolicyResponse], ContinuationToken: null }, PolicyResponse),
);
mockPolicyListService = mock<PolicyListService>();
mockPolicyListService.getPolicies.mockReturnValue([mockPolicy]);
mockDialogService = mock<DialogService>();
mockDialogService.open.mockReturnValue({ close: jest.fn() } as any);
mockPolicyService = mock<PolicyService>();
mockPolicyService.policies$.mockReturnValue(of([]));
mockConfigService = mock<ConfigService>();
mockI18nService = mock<I18nService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
jest.spyOn(PolicyEditDialogComponent, "open").mockReturnValue({ close: jest.fn() } as any);
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
if (fixture) {
fixture.destroy();
}
jest.restoreAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("organizationId$", () => {
it("should extract organizationId from route params", async () => {
const orgId = await firstValueFrom(component.organizationId$);
expect(orgId).toBe(mockOrgId);
});
it("should emit new organizationId when route params change", (done) => {
const newOrgId = newGuid() as OrganizationId;
const emittedValues: OrganizationId[] = [];
const subscription = component.organizationId$.subscribe((orgId) => {
emittedValues.push(orgId);
if (emittedValues.length === 2) {
expect(emittedValues[0]).toBe(mockOrgId);
expect(emittedValues[1]).toBe(newOrgId);
subscription.unsubscribe();
done();
}
});
routeParamsSubject.next({ organizationId: newOrgId });
});
});
describe("organization$", () => {
it("should retrieve organization for current user and organizationId", async () => {
const org = await firstValueFrom(component.organization$);
expect(org).toBe(mockOrg);
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
});
it("should throw error when organization is not found", async () => {
mockOrganizationService.organizations$.mockReturnValue(of([]));
await expect(firstValueFrom(component.organization$)).rejects.toThrow(
"No organization found for provided userId",
);
});
});
describe("policies$", () => {
it("should return policies from PolicyListService", async () => {
const policies = await firstValueFrom(component.policies$);
expect(policies).toBeDefined();
expect(Array.isArray(policies)).toBe(true);
});
});
describe("orgPolicies$", () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: newGuid(),
organizationId: mockOrgId,
type: PolicyType.TwoFactorAuthentication,
enabled: true,
data: null,
},
{
id: newGuid(),
organizationId: mockOrgId,
type: PolicyType.RequireSso,
enabled: false,
data: null,
},
];
beforeEach(async () => {
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should fetch policies from API for current organization", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies.length).toBe(2);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
});
});
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns no data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
describe("with null data", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns null data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
});
describe("policiesEnabledMap$", () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: "policy-1",
organizationId: mockOrgId,
type: PolicyType.TwoFactorAuthentication,
enabled: true,
data: null,
},
{
id: "policy-2",
organizationId: mockOrgId,
type: PolicyType.RequireSso,
enabled: false,
data: null,
},
{
id: "policy-3",
organizationId: mockOrgId,
type: PolicyType.SingleOrg,
enabled: true,
data: null,
},
];
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create a map of policy types to their enabled status", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
});
});
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create empty map when no policies exist", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
});
});
});
describe("constructor subscription", () => {
it("should subscribe to policyService.policies$ on initialization", () => {
expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId);
});
describe("when policyService emits", () => {
let policiesSubject: BehaviorSubject<any[]>;
let callCount: number;
beforeEach(async () => {
policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
});
fixture = TestBed.createComponent(PoliciesComponent);
fixture.detectChanges();
});
it("should refresh policies when policyService emits", () => {
const initialCallCount = callCount;
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
expect(callCount).toBeGreaterThan(initialCallCount);
});
});
});
describe("handleLaunchEvent", () => {
describe("when policyId is in query params", () => {
const mockPolicyId = newGuid();
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.TwoFactorAuthentication,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
const mockPolicyResponseData = {
id: mockPolicyId,
organizationId: mockOrgId,
type: PolicyType.TwoFactorAuthentication,
enabled: true,
data: null,
};
let dialogOpenSpy: jest.SpyInstance;
beforeEach(async () => {
queryParamsSubject.next({ policyId: mockPolicyId });
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
),
),
);
dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should open policy dialog when policyId is in query params", () => {
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
});
});
it("should not open dialog when policyId is not in query params", async () => {
const editSpy = jest.spyOn(component, "edit");
queryParamsSubject.next({});
expect(editSpy).not.toHaveBeenCalled();
});
it("should not open dialog when policyId does not match any org policy", async () => {
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.TwoFactorAuthentication,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
mockPolicyListService.getPolicies.mockReturnValue([mockPolicy]);
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const editSpy = jest.spyOn(component, "edit");
queryParamsSubject.next({ policyId: "non-existent-policy-id" });
expect(editSpy).not.toHaveBeenCalled();
});
});
describe("edit", () => {
it("should call dialogService.open with correct parameters when no custom dialog is specified", () => {
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.TwoFactorAuthentication,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
component.edit(mockPolicy, mockOrgId);
expect(openSpy).toHaveBeenCalled();
const callArgs = openSpy.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organizationId: mockOrgId,
},
});
});
it("should call custom dialog open method when specified", () => {
const mockDialogRef = { close: jest.fn() };
const mockCustomDialog = {
open: jest.fn().mockReturnValue(mockDialogRef),
};
const mockPolicy: BasePolicyEditDefinition = {
name: "Custom Policy",
description: "Custom Description",
type: PolicyType.RequireSso,
component: {} as any,
editDialogComponent: mockCustomDialog as any,
showDescription: true,
display$: () => of(true),
};
component.edit(mockPolicy, mockOrgId);
expect(mockCustomDialog.open).toHaveBeenCalled();
const callArgs = mockCustomDialog.open.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organizationId: mockOrgId,
},
});
expect(PolicyEditDialogComponent.open).not.toHaveBeenCalled();
});
it("should pass correct organizationId to dialog", () => {
const customOrgId = newGuid() as OrganizationId;
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.SingleOrg,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
component.edit(mockPolicy, customOrgId);
expect(openSpy).toHaveBeenCalled();
const callArgs = openSpy.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organizationId: customOrgId,
},
});
});
});
});

View File

@@ -1,31 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
firstValueFrom,
Observable,
of,
switchMap,
first,
map,
withLatestFrom,
tap,
} from "rxjs";
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
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";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
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 { getById } from "@bitwarden/common/platform/misc";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { safeProvider } from "@bitwarden/ui-common";
@@ -37,8 +25,6 @@ import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
// 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: "policies.component.html",
imports: [SharedModule, HeaderModule],
@@ -48,14 +34,54 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token";
deps: [POLICY_EDIT_REGISTER],
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PoliciesComponent implements OnInit {
loading = true;
organizationId: string;
policies$: Observable<BasePolicyEditDefinition[]>;
export class PoliciesComponent {
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
private orgPolicies: PolicyResponse[];
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
protected organizationId$: Observable<OrganizationId> = this.route.params.pipe(
map((params) => params.organizationId),
);
protected organization$: Observable<Organization> = combineLatest([
this.userId$,
this.organizationId$,
]).pipe(
switchMap(([userId, orgId]) =>
this.organizationService.organizations$(userId).pipe(
getById(orgId),
map((org) => {
if (org == null) {
throw new Error("No organization found for provided userId");
}
return org;
}),
),
),
);
protected policies$: Observable<readonly BasePolicyEditDefinition[]> = of(
this.policyListService.getPolicies(),
);
private orgPolicies$: Observable<PolicyResponse[]> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
switchMap(() => this.organizationId$),
switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)),
map((response) => (response.data != null && response.data.length > 0 ? response.data : [])),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected policiesEnabledMap$: Observable<Map<PolicyType, boolean>> = this.orgPolicies$.pipe(
map((orgPolicies) => {
const policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
orgPolicies.forEach((op) => {
policiesEnabledMap.set(op.type, op.enabled);
});
return policiesEnabledMap;
}),
);
constructor(
private route: ActivatedRoute,
@@ -66,60 +92,28 @@ export class PoliciesComponent implements OnInit {
private dialogService: DialogService,
private policyService: PolicyService,
protected configService: ConfigService,
private destroyRef: DestroyRef,
) {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
tap(async () => await this.load()),
takeUntilDestroyed(),
)
.subscribe();
this.handleLaunchEvent();
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const organization$ = this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId));
this.policies$ = organization$.pipe(
withLatestFrom(of(this.policyListService.getPolicies())),
switchMap(([organization, policies]) => {
return combineLatest(
policies.map((policy) =>
policy
.display$(organization, this.configService)
.pipe(map((shouldDisplay) => ({ policy, shouldDisplay }))),
),
);
}),
map((results) =>
results.filter((result) => result.shouldDisplay).map((result) => result.policy),
),
);
await this.load();
// Handle policies component launch from Event message
combineLatest([this.route.queryParams.pipe(first()), this.policies$])
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
.subscribe(async ([qParams, policies]) => {
// Handle policies component launch from Event message
private handleLaunchEvent() {
combineLatest([
this.route.queryParams.pipe(first()),
this.policies$,
this.organizationId$,
this.orgPolicies$,
])
.pipe(
map(([qParams, policies, organizationId, orgPolicies]) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
for (const orgPolicy of orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < policies.length; i++) {
if (policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(policies[i]);
this.edit(policies[i], organizationId);
break;
}
}
@@ -127,27 +121,19 @@ export class PoliciesComponent implements OnInit {
}
}
}
});
});
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
async load() {
const response = await this.policyApiService.getPolicies(this.organizationId);
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
this.orgPolicies.forEach((op) => {
this.policiesEnabledMap.set(op.type, op.enabled);
});
this.loading = false;
}
async edit(policy: BasePolicyEditDefinition) {
edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) {
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organizationId: this.organizationId,
organizationId: organizationId,
},
});
}

View File

@@ -1,4 +1,11 @@
import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
OnInit,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { BehaviorSubject, map, Observable } from "rxjs";
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
@@ -26,11 +33,11 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auto-confirm-policy-edit",
templateUrl: "auto-confirm-policy.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
protected readonly autoConfirmSvg = AutoConfirmSvg;

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -18,10 +18,10 @@ export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinitio
return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype);
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "autotype-policy-edit",
templateUrl: "autotype-policy.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DesktopAutotypeDefaultSettingPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,10 +12,10 @@ export class DisableSendPolicy extends BasePolicyEditDefinition {
component = DisableSendPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "disable-send-policy-edit",
templateUrl: "disable-send.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DisableSendPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
@@ -26,11 +26,11 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition {
component = MasterPasswordPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "master-password-policy-edit",
templateUrl: "master-password.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
MinPasswordLength = Utils.minimumPasswordLength;

View File

@@ -1,9 +1,8 @@
import { Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { of, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
@@ -16,16 +15,15 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
component = OrganizationDataOwnershipPolicyComponent;
display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
.pipe(map((enabled) => !enabled));
// TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag
return of(false);
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "organization-data-ownership-policy-edit",
templateUrl: "organization-data-ownership.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { BehaviorSubject, map } from "rxjs";
@@ -19,11 +19,11 @@ export class PasswordGeneratorPolicy extends BasePolicyEditDefinition {
component = PasswordGeneratorPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "password-generator-policy-edit",
templateUrl: "password-generator.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PasswordGeneratorPolicyComponent extends BasePolicyEditComponent {
// these properties forward the application default settings to the UI

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,10 +12,10 @@ export class RemoveUnlockWithPinPolicy extends BasePolicyEditDefinition {
component = RemoveUnlockWithPinPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "remove-unlock-with-pin-policy-edit",
templateUrl: "remove-unlock-with-pin.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemoveUnlockWithPinPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -19,10 +19,10 @@ export class RequireSsoPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "require-sso-policy-edit",
templateUrl: "require-sso.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RequireSsoPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom, of } from "rxjs";
@@ -26,11 +26,11 @@ export class ResetPasswordPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "reset-password-policy-edit",
templateUrl: "reset-password.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ResetPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
data = this.formBuilder.group({

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,11 +12,11 @@ export class RestrictedItemTypesPolicy extends BasePolicyEditDefinition {
component = RestrictedItemTypesPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "restricted-item-types-policy-edit",
templateUrl: "restricted-item-types.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RestrictedItemTypesPolicyComponent extends BasePolicyEditComponent {
constructor() {

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -13,11 +13,11 @@ export class SendOptionsPolicy extends BasePolicyEditDefinition {
component = SendOptionsPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "send-options-policy-edit",
templateUrl: "send-options.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendOptionsPolicyComponent extends BasePolicyEditComponent {
data = this.formBuilder.group({

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,11 +12,11 @@ export class SingleOrgPolicy extends BasePolicyEditDefinition {
component = SingleOrgPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "single-org-policy-edit",
templateUrl: "single-org.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SingleOrgPolicyComponent extends BasePolicyEditComponent implements OnInit {
async ngOnInit() {

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,10 +12,10 @@ export class TwoFactorAuthenticationPolicy extends BasePolicyEditDefinition {
component = TwoFactorAuthenticationPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "two-factor-authentication-policy-edit",
templateUrl: "two-factor-authentication.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TwoFactorAuthenticationPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -19,6 +19,7 @@ export class UriMatchDefaultPolicy extends BasePolicyEditDefinition {
component = UriMatchDefaultPolicyComponent;
}
@Component({
selector: "uri-match-default-policy-edit",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "uri-match-default.component.html",
imports: [SharedModule],

View File

@@ -1,12 +1,9 @@
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, Observable } from "rxjs";
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
@@ -28,17 +25,13 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "vnext-organization-data-ownership-policy-edit",
templateUrl: "vnext-organization-data-ownership.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class vNextOrganizationDataOwnershipPolicyComponent
extends BasePolicyEditComponent

View File

@@ -14,8 +14,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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 { OrganizationId } from "@bitwarden/common/types/guid";
import {
@@ -75,7 +73,6 @@ export class PolicyEditDialogComponent implements AfterViewInit {
private formBuilder: FormBuilder,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private configService: ConfigService,
private keyService: KeyService,
) {}
@@ -132,10 +129,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
try {
if (
this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent &&
(await this.isVNextEnabled())
) {
if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) {
await this.handleVNextSubmission(this.policyComponent);
} else {
await this.handleStandardSubmission();
@@ -154,14 +148,6 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
};
private async isVNextEnabled(): Promise<boolean> {
const isVNextFeatureEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
);
return isVNextFeatureEnabled;
}
private async handleStandardSubmission(): Promise<void> {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");

View File

@@ -168,18 +168,11 @@ export class AccountComponent implements OnInit, OnDestroy {
return;
}
const request = new OrganizationUpdateRequest();
/*
* When you disable a FormControl, it is removed from formGroup.values, so we have to use
* the original value.
* */
request.name = this.formGroup.get("orgName").disabled
? this.org.name
: this.formGroup.value.orgName;
request.billingEmail = this.formGroup.get("billingEmail").disabled
? this.org.billingEmail
: this.formGroup.value.billingEmail;
// The server ignores any undefined values, so it's ok to reference disabled form fields here
const request: OrganizationUpdateRequest = {
name: this.formGroup.value.orgName,
billingEmail: this.formGroup.value.billingEmail,
};
// Backfill pub/priv key if necessary
if (!this.org.hasPublicAndPrivateKeys) {

View File

@@ -57,7 +57,7 @@ const routes: Routes = [
),
canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)],
data: {
titleId: "importData",
titleId: "importNoun",
},
},
{
@@ -68,7 +68,7 @@ const routes: Routes = [
),
canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)],
data: {
titleId: "exportVault",
titleId: "exportNoun",
},
},
],

View File

@@ -1,4 +1,4 @@
import { action } from "@storybook/addon-actions";
import { action } from "storybook/actions";
import { AccessItemType, AccessItemView } from "./access-selector.models";

View File

@@ -27,7 +27,7 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService, RouterFocusManagerService, ToastService } from "@bitwarden/components";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
const BroadcasterSubscriptionId = "AppComponent";
@@ -76,11 +76,17 @@ export class AppComponent implements OnDestroy, OnInit {
private readonly destroy: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
private readonly tokenService: TokenService,
private readonly routerFocusManager: RouterFocusManagerService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destroy.onDestroy(() => langSubscription.unsubscribe());
this.routerFocusManager.start$.pipe(takeUntilDestroyed()).subscribe();
this.destroy.onDestroy(() => {
langSubscription.unsubscribe();
});
}
ngOnInit() {

View File

@@ -61,8 +61,11 @@ export class WebLoginComponentService
email: string,
state: string,
codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> {
await this.router.navigate(["/sso"]);
await this.router.navigate(["/sso"], {
queryParams: { identifier: orgSsoIdentifier },
});
return;
}

View File

@@ -16,6 +16,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -45,6 +46,7 @@ describe("WebSetInitialPasswordService", () => {
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let organizationInviteService: MockProxy<OrganizationInviteService>;
let routerService: MockProxy<RouterService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
beforeEach(() => {
apiService = mock<ApiService>();
@@ -59,6 +61,7 @@ describe("WebSetInitialPasswordService", () => {
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
organizationInviteService = mock<OrganizationInviteService>();
routerService = mock<RouterService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
sut = new WebSetInitialPasswordService(
apiService,
@@ -73,6 +76,7 @@ describe("WebSetInitialPasswordService", () => {
userDecryptionOptionsService,
organizationInviteService,
routerService,
accountCryptographicStateService,
);
});
@@ -123,7 +127,9 @@ describe("WebSetInitialPasswordService", () => {
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,

View File

@@ -10,6 +10,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -34,6 +35,7 @@ export class WebSetInitialPasswordService
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private organizationInviteService: OrganizationInviteService,
private routerService: RouterService,
protected accountCryptographicStateService: AccountCryptographicStateService,
) {
super(
apiService,
@@ -46,6 +48,7 @@ export class WebSetInitialPasswordService
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
);
}

View File

@@ -39,9 +39,7 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service
/**
* Service for managing WebAuthnLogin credentials.
*/
export class WebauthnLoginAdminService
implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest>
{
export class WebauthnLoginAdminService implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest> {
static readonly MaxCredentialCount = 5;
private navigatorCredentials: CredentialsContainer;

View File

@@ -45,13 +45,10 @@ import { EmergencyAccessGranteeDetailsResponse } from "../response/emergency-acc
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@Injectable()
export class EmergencyAccessService
implements
UserKeyRotationKeyRecoveryProvider<
EmergencyAccessWithIdRequest,
GranteeEmergencyAccessWithPublicKey
>
{
export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvider<
EmergencyAccessWithIdRequest,
GranteeEmergencyAccessWithPublicKey
> {
constructor(
private emergencyAccessApiService: EmergencyAccessApiService,
private apiService: ApiService,

View File

@@ -1,21 +1,37 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { DeleteRecoverRequest } from "@bitwarden/common/models/request/delete-recover.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-delete",
templateUrl: "recover-delete.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
FormFieldModule,
I18nPipe,
TypographyModule,
],
})
export class RecoverDeleteComponent {
protected recoverDeleteForm = new FormGroup({
@@ -29,7 +45,6 @@ export class RecoverDeleteComponent {
constructor(
private router: Router,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private toastService: ToastService,
) {}

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { Router, provideRouter } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import {
@@ -7,69 +7,49 @@ import {
LoginSuccessHandlerService,
PasswordLoginCredentials,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { I18nPipe } from "@bitwarden/ui-common";
import { RecoverTwoFactorComponent } from "./recover-two-factor.component";
describe("RecoverTwoFactorComponent", () => {
let component: RecoverTwoFactorComponent;
let fixture: ComponentFixture<RecoverTwoFactorComponent>;
// Mock Services
let mockRouter: MockProxy<Router>;
let mockApiService: MockProxy<ApiService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockI18nService: MockProxy<I18nService>;
let mockKeyService: MockProxy<KeyService>;
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let mockToastService: MockProxy<ToastService>;
let mockConfigService: MockProxy<ConfigService>;
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
let mockLogService: MockProxy<LogService>;
let mockValidationService: MockProxy<ValidationService>;
beforeEach(() => {
mockRouter = mock<Router>();
mockApiService = mock<ApiService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
beforeEach(async () => {
mockI18nService = mock<I18nService>();
mockKeyService = mock<KeyService>();
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockToastService = mock<ToastService>();
mockConfigService = mock<ConfigService>();
mockLoginSuccessHandlerService = mock<LoginSuccessHandlerService>();
mockLogService = mock<LogService>();
mockValidationService = mock<ValidationService>();
TestBed.configureTestingModule({
declarations: [RecoverTwoFactorComponent],
await TestBed.configureTestingModule({
imports: [RecoverTwoFactorComponent],
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: ApiService, useValue: mockApiService },
{ provide: PlatformUtilsService, mockPlatformUtilsService },
provideRouter([]),
{ provide: I18nService, useValue: mockI18nService },
{ provide: KeyService, useValue: mockKeyService },
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
{ provide: ToastService, useValue: mockToastService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService },
{ provide: LogService, useValue: mockLogService },
{ provide: ValidationService, useValue: mockValidationService },
],
imports: [I18nPipe],
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
errorOnUnknownElements: false,
});
}).compileComponents();
mockRouter = TestBed.inject(Router) as MockProxy<Router>;
jest.spyOn(mockRouter, "navigate");
fixture = TestBed.createComponent(RecoverTwoFactorComponent);
component = fixture.componentInstance;

View File

@@ -1,8 +1,9 @@
import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginStrategyServiceAbstraction,
PasswordLoginCredentials,
@@ -14,14 +15,32 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
LinkModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-two-factor",
templateUrl: "recover-two-factor.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
FormFieldModule,
I18nPipe,
LinkModule,
TypographyModule,
],
})
export class RecoverTwoFactorComponent implements OnInit {
formGroup = new FormGroup({
@@ -108,7 +127,7 @@ export class RecoverTwoFactorComponent implements OnInit {
message: this.i18nService.t("twoStepRecoverDisabled"),
});
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword);
await this.router.navigate(["/settings/security/two-factor"]);
} catch (error: unknown) {

View File

@@ -1,11 +1,10 @@
import { Component, OnInit, OnDestroy } from "@angular/core";
import { firstValueFrom, from, lastValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
import { firstValueFrom, lastValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -42,8 +41,7 @@ export class AccountComponent implements OnInit, OnDestroy {
constructor(
private accountService: AccountService,
private dialogService: DialogService,
private userVerificationService: UserVerificationService,
private configService: ConfigService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private organizationService: OrganizationService,
) {}
@@ -56,7 +54,7 @@ export class AccountComponent implements OnInit, OnDestroy {
map((organizations) => organizations.some((o) => o.userIsManagedByOrganization === true)),
);
const hasMasterPassword$ = from(this.userVerificationService.hasMasterPassword());
const hasMasterPassword$ = this.userDecryptionOptionsService.hasMasterPasswordById$(userId);
this.showChangeEmail$ = hasMasterPassword$;

View File

@@ -19,7 +19,7 @@
>
<ng-container *ngIf="currentCipher.organizationId">
<i
class="bwi bwi-collection-shared"
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
@@ -28,7 +28,7 @@
</ng-container>
<ng-container *ngIf="currentCipher.hasAttachments">
<i
class="bwi bwi-paperclip"
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"

View File

@@ -5,6 +5,8 @@ import { firstValueFrom } from "rxjs";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
import { InputPasswordFlow } from "@bitwarden/auth/angular";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CalloutModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -24,12 +26,15 @@ export class PasswordSettingsComponent implements OnInit {
constructor(
private router: Router,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private accountService: AccountService,
) {}
async ngOnInit() {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const userHasMasterPassword = await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPassword$,
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
);
if (!userHasMasterPassword) {
await this.router.navigate(["/settings/security/two-factor"]);
return;

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DialogService } from "@bitwarden/components";
import { ChangeKdfModule } from "../../../key-management/change-kdf/change-kdf.module";
@@ -23,20 +22,28 @@ export class SecurityKeysComponent implements OnInit {
showChangeKdf = true;
constructor(
private userVerificationService: UserVerificationService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private accountService: AccountService,
private apiService: ApiService,
private dialogService: DialogService,
) {}
async ngOnInit() {
this.showChangeKdf = await this.userVerificationService.hasMasterPassword();
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.showChangeKdf = await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
);
}
async viewUserApiKey() {
const entityId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!entityId) {
throw new Error("Active account not found");
}
await ApiKeyComponent.open(this.dialogService, {
data: {
keyType: "user",
@@ -55,6 +62,11 @@ export class SecurityKeysComponent implements OnInit {
const entityId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!entityId) {
throw new Error("Active account not found");
}
await ApiKeyComponent.open(this.dialogService, {
data: {
keyType: "user",

View File

@@ -1,7 +1,9 @@
import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -20,7 +22,8 @@ export class SecurityComponent implements OnInit {
consolidatedSessionTimeoutComponent$: Observable<boolean>;
constructor(
private userVerificationService: UserVerificationService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private accountService: AccountService,
private configService: ConfigService,
) {
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
@@ -29,6 +32,9 @@ export class SecurityComponent implements OnInit {
}
async ngOnInit() {
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.showChangePassword = userId
? await firstValueFrom(this.userDecryptionOptionsService.hasMasterPasswordById$(userId))
: false;
}
}

View File

@@ -16,27 +16,26 @@
<img class="tw-float-right tw-ml-5 mfaType7" alt="FIDO2 WebAuthn logo" />
<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-medium">
{{ "webAuthnkeyX" | i18n: (i + 1).toString() }}
</span>
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-medium">
{{ k.name }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">
<ng-container *ngIf="k.migrated">
<span>{{ "webAuthnMigrated" | i18n }}</span>
<ng-container *ngIf="k.configured">
<i class="bwi bwi-li bwi-key"></i>
<span *ngIf="k.configured" bitTypography="body1" class="tw-font-medium">
{{ k.name || ("unnamedKey" | i18n) }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">
<ng-container *ngIf="k.migrated">
<span>{{ "webAuthnMigrated" | i18n }}</span>
</ng-container>
</ng-container>
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
<i
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
title="{{ 'loading' | i18n }}"
*ngIf="$any(removeKeyBtn).loading"
aria-hidden="true"
></i>
-
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
</ng-container>
</ng-container>
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
<i
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
title="{{ 'loading' | i18n }}"
*ngIf="$any(removeKeyBtn).loading"
aria-hidden="true"
></i>
-
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
</ng-container>
</li>
</ul>
@@ -60,7 +59,9 @@
type="button"
[bitAction]="readKey"
buttonType="secondary"
[disabled]="$any(readKeyBtn).loading() || webAuthnListening || !keyIdAvailable"
[disabled]="
$any(readKeyBtn).loading() || webAuthnListening || !keyIdAvailable || formGroup.invalid
"
class="tw-mr-2"
#readKeyBtn
>

View File

@@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, Inject, NgZone } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -99,7 +99,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
toastService,
);
this.formGroup = new FormGroup({
name: new FormControl({ value: "", disabled: false }),
name: new FormControl({ value: "", disabled: false }, Validators.required),
});
this.auth(data);
}
@@ -213,7 +213,22 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
this.webAuthnListening = listening;
}
private findNextAvailableKeyId(existingIds: Set<number>): number {
// Search for first gap, bounded by current key count + 1
for (let i = 1; i <= existingIds.size + 1; i++) {
if (!existingIds.has(i)) {
return i;
}
}
// This should never be reached due to loop bounds, but TypeScript requires a return
throw new Error("Unable to find next available key ID");
}
private processResponse(response: TwoFactorWebAuthnResponse) {
if (!response.keys || response.keys.length === 0) {
response.keys = [];
}
this.resetWebAuthn();
this.keys = [];
this.keyIdAvailable = null;
@@ -223,26 +238,37 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
nameControl.setValue("");
}
this.keysConfiguredCount = 0;
for (let i = 1; i <= 5; i++) {
if (response.keys != null) {
const key = response.keys.filter((k) => k.id === i);
if (key.length > 0) {
this.keysConfiguredCount++;
this.keys.push({
id: i,
name: key[0].name,
configured: true,
migrated: key[0].migrated,
removePromise: null,
});
continue;
}
}
this.keys.push({ id: i, name: "", configured: false, removePromise: null });
if (this.keyIdAvailable == null) {
this.keyIdAvailable = i;
}
// Build configured keys
for (const key of response.keys) {
this.keysConfiguredCount++;
this.keys.push({
id: key.id,
name: key.name,
configured: true,
migrated: key.migrated,
removePromise: null,
});
}
// [PM-20109]: To accommodate the existing form logic with minimal changes,
// we need to have at least one unconfigured key slot available to the collection.
// Prior to PM-20109, both client and server had hard checks for IDs <= 5.
// While we don't have any technical constraints _at this time_, we should avoid
// unbounded growth of key IDs over time as users add/remove keys;
// this strategy gap-fills key IDs.
const existingIds = new Set(response.keys.map((k) => k.id));
const nextId = this.findNextAvailableKeyId(existingIds);
// Add unconfigured slot, which can be used to add a new key
this.keys.push({
id: nextId,
name: "",
configured: false,
removePromise: null,
});
this.keyIdAvailable = nextId;
this.enabled = response.enabled;
this.onUpdated.emit(this.enabled);
}

View File

@@ -10,7 +10,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { VerifyEmailRequest } from "@bitwarden/common/models/request/verify-email.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -18,12 +17,10 @@ import { ToastService } from "@bitwarden/components";
@Component({
selector: "app-verify-email-token",
templateUrl: "verify-email-token.component.html",
standalone: false,
})
export class VerifyEmailTokenComponent implements OnInit {
constructor(
private router: Router,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private apiService: ApiService,

View File

@@ -1,5 +1,5 @@
<form [bitSubmit]="submit" [formGroup]="formGroup">
<app-callout type="warning">{{ "deleteAccountWarning" | i18n }}</app-callout>
<bit-callout type="warning">{{ "deleteAccountWarning" | i18n }}</bit-callout>
<p bitTypography="body1" class="tw-text-center">
<strong>{{ email }}</strong>
</p>

View File

@@ -1,22 +1,36 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { FormGroup, ReactiveFormsModule } from "@angular/forms";
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { first } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VerifyDeleteRecoverRequest } from "@bitwarden/common/models/request/verify-delete-recover.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
CalloutComponent,
ToastService,
TypographyModule,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-recover-delete",
templateUrl: "verify-recover-delete.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
CalloutComponent,
TypographyModule,
],
})
export class VerifyRecoverDeleteComponent implements OnInit {
email: string;
@@ -28,7 +42,6 @@ export class VerifyRecoverDeleteComponent implements OnInit {
constructor(
private router: Router,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private toastService: ToastService,

View File

@@ -1,15 +1,11 @@
import { inject, NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
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 { 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";
@@ -27,20 +23,15 @@ const routes: Routes = [
data: { titleId: "premiumMembership" },
},
/**
* Three-Route Matching Strategy for /premium:
* Two-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
* 2. Cloud-Hosted (default) → CloudHostedPremiumComponent
* - Evaluated when Route 1 doesn't match (not self-hosted)
*/
// Route 1: Self-Hosted -> SelfHostedPremiumComponent
{
@@ -54,22 +45,7 @@ const routes: Routes = [
},
],
},
// 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));
},
],
},
// Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback)
// Route 2: Cloud Hosted (default) -> CloudHostedPremiumComponent
{
path: "premium",
component: CloudHostedPremiumComponent,

View File

@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { BaseCardComponent } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import {
EnterBillingAddressComponent,
@@ -11,7 +12,6 @@ import { BillingSharedModule } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -23,12 +23,8 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
PricingCardComponent,
BaseCardComponent,
],
declarations: [
SubscriptionComponent,
BillingHistoryViewComponent,
UserSubscriptionComponent,
CloudHostedPremiumComponent,
],
declarations: [SubscriptionComponent, BillingHistoryViewComponent, UserSubscriptionComponent],
})
export class IndividualBillingModule {}

View File

@@ -1,68 +0,0 @@
<div class="tw-max-w-3xl tw-mx-auto">
<bit-section *ngIf="shouldShowNewDesign$ | async">
<div class="tw-text-center">
<div class="tw-mt-8 tw-mb-6">
<span bitBadge variant="secondary" [truncate]="false">
{{ "bitwardenFreeplanMessage" | i18n }}
</span>
</div>
<h2 class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<!-- Two-Card Layout -->
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-6 tw-mt-6 tw-justify-center">
<!-- Premium Card -->
<div>
@if (premiumCardData$ | async; as premiumData) {
<billing-pricing-card
[tagline]="'planDescPremium' | i18n"
[price]="{ amount: premiumData.price, cadence: 'monthly' }"
[button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }"
[features]="premiumData.features"
(buttonClick)="openUpgradeDialog('Premium')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "premium" | i18n }}</h3>
</billing-pricing-card>
}
</div>
<!-- Families Card -->
<div>
@if (familiesCardData$ | async; as familiesData) {
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
[button]="{ type: 'secondary', text: ('startFreeFamiliesTrial' | i18n) }"
[features]="familiesData.features"
(buttonClick)="openUpgradeDialog('Families')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "families" | i18n }}</h3>
</billing-pricing-card>
}
</div>
</div>
<!-- Business Plans Link -->
<div class="tw-text-center tw-mt-6">
<p class="tw-text-muted tw-mb-2 tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<a
bitLink
linkType="primary"
href="https://bitwarden.com/pricing/business/"
target="_blank"
rel="noopener noreferrer"
>
{{ "viewbusinessplans" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-section>
</div>

View File

@@ -1,242 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
from,
map,
Observable,
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 { 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,
DialogService,
LinkModule,
SectionComponent,
TypographyModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
UnifiedUpgradeDialogStep,
} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
const RouteParams = {
callToAction: "callToAction",
} as const;
const RouteParamValues = {
upgradeToPremium: "upgradeToPremium",
} as const;
// 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: "./cloud-hosted-premium-vnext.component.html",
standalone: true,
imports: [
CommonModule,
SectionComponent,
BadgeModule,
TypographyModule,
LinkModule,
I18nPipe,
PricingCardComponent,
],
})
export class CloudHostedPremiumVNextComponent {
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
protected shouldShowUpgradeDialogOnInit$: Observable<boolean>;
protected personalPricingTiers$: Observable<PersonalSubscriptionPricingTier[]>;
protected premiumCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected familiesCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private apiService: ApiService,
private dialogService: DialogService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
this.accountService.activeAccount$
.pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef))
.subscribe((subscriber) => {
this.subscriber = subscriber;
});
this.shouldShowNewDesign$ = combineLatest([
this.hasPremiumFromAnyOrganization$,
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
// redirect to user subscription page if they already have premium personally
// redirect to individual vault if they already have premium from an org
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
if (hasPremiumFromOrg) {
return from(this.navigateToIndividualVault());
}
return of(true);
}),
)
.subscribe();
this.shouldShowUpgradeDialogOnInit$ = combineLatest([
this.hasPremiumFromAnyOrganization$,
this.hasPremiumPersonally$,
this.activatedRoute.queryParams,
]).pipe(
map(([hasOrgPremium, hasPersonalPremium, queryParams]) => {
const cta = queryParams[RouteParams.callToAction];
return !hasOrgPremium && !hasPersonalPremium && cta === RouteParamValues.upgradeToPremium;
}),
);
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.premiumCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
return {
tier,
price:
tier?.passwordManager.type === "standalone"
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.familiesCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Families);
return {
tier,
price:
tier?.passwordManager.type === "packaged"
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.shouldShowUpgradeDialogOnInit$
.pipe(
take(1),
switchMap((shouldShowUpgradeDialogOnInit) => {
if (shouldShowUpgradeDialogOnInit) {
return from(this.openUpgradeDialog("Premium"));
}
// Return an Observable that completes immediately when dialog should not be shown
return of(void 0);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
private navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => this.router.navigate(["/vault"]);
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise<void> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const selectedPlan =
planType === "Premium"
? PersonalSubscriptionPricingTierIds.Premium
: PersonalSubscriptionPricingTierIds.Families;
const dialogParams: UnifiedUpgradeDialogParams = {
account,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: selectedPlan,
redirectOnCompletion: true,
};
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: dialogParams,
});
dialogRef.closed
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: UnifiedUpgradeDialogResult | undefined) => {
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
void this.finalizeUpgrade();
}
});
}
}

View File

@@ -1,138 +1,68 @@
@if (isLoadingPrices$ | async) {
<ng-container>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
} @else {
<bit-container>
<bit-section>
<h2 bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
<div class="tw-max-w-3xl tw-mx-auto">
<bit-section *ngIf="shouldShowNewDesign$ | async">
<div class="tw-text-center">
<div class="tw-mt-8 tw-mb-6">
<span bitBadge variant="secondary" [truncate]="false">
{{ "bitwardenFreeplanMessage" | i18n }}
</span>
</div>
<h2 class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<!-- Two-Card Layout -->
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-6 tw-mt-6 tw-justify-center">
<!-- Premium Card -->
<div>
@if (premiumCardData$ | async; as premiumData) {
<billing-pricing-card
[tagline]="'advancedOnlineSecurity' | i18n"
[price]="{ amount: premiumData.price, cadence: 'monthly' }"
[button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }"
[features]="premiumData.features"
(buttonClick)="openUpgradeDialog('Premium')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "premium" | i18n }}</h3>
</billing-pricing-card>
}
</div>
<!-- Families Card -->
<div>
@if (familiesCardData$ | async; as familiesData) {
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
[button]="{ type: 'secondary', text: ('startFreeFamiliesTrial' | i18n) }"
[features]="familiesData.features"
(buttonClick)="openUpgradeDialog('Families')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "families" | i18n }}</h3>
</billing-pricing-card>
}
</div>
</div>
<!-- Business Plans Link -->
<div class="tw-text-center tw-mt-6">
<p class="tw-text-muted tw-mb-2 tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<a
bitLink
linkType="primary"
href="https://bitwarden.com/pricing/business/"
target="_blank"
rel="noopener noreferrer"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<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>
<p bitTypography="body1" class="tw-mb-0">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
</bit-callout>
</bit-section>
<form [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storagePrice$ | async | currency: "$" }} =
{{ storageCost$ | async | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
[showAccountCredit]="true"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total$ | async | currency: "USD $" }}/{{
"year" | i18n
}}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
}
{{ "viewbusinessplans" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-section>
</div>

View File

@@ -1,240 +1,242 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, ViewChild } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
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,
firstValueFrom,
from,
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
take,
} from "rxjs";
import { debounceTime } from "rxjs/operators";
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 { 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 { ToastService } from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
BadgeModule,
DialogService,
LinkModule,
SectionComponent,
TypographyModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
import {
NonTokenizablePaymentMethods,
tokenizablePaymentMethodToLegacyEnum,
} from "@bitwarden/web-vault/app/billing/payment/types";
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
UnifiedUpgradeDialogStep,
} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
const RouteParams = {
callToAction: "callToAction",
} as const;
const RouteParamValues = {
upgradeToPremium: "upgradeToPremium",
} as const;
// 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: "./cloud-hosted-premium.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
standalone: true,
imports: [
CommonModule,
SectionComponent,
BadgeModule,
TypographyModule,
LinkModule,
I18nPipe,
PricingCardComponent,
],
})
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;
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasEnoughAccountCredit$: Observable<boolean>;
protected formGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
map((tiers) => {
const premiumPlan = tiers.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
if (!premiumPlan) {
throw new Error("Could not find Premium plan");
}
return {
seat: premiumPlan.passwordManager.annualPrice,
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat));
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
protected isLoadingPrices$ = this.premiumPrices$.pipe(
map(() => false),
startWith(true),
catchError(() => of(false)),
);
storageCost$ = combineLatest([
this.storagePrice$,
this.formGroup.controls.additionalStorage.valueChanges.pipe(
startWith(this.formGroup.value.additionalStorage),
),
]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage));
subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe(
map(([premiumPrice, storageCost]) => premiumPrice + storageCost),
);
tax$ = this.formGroup.valueChanges.pipe(
filter(() => this.formGroup.valid),
debounceTime(1000),
switchMap(async () => {
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
this.formGroup.value.additionalStorage,
billingAddress,
);
return taxAmounts.tax;
}),
startWith(0),
);
total$ = combineLatest([this.subtotal$, this.tax$]).pipe(
map(([subtotal, tax]) => subtotal + tax),
);
protected cloudWebVaultURL: string;
protected readonly familyPlanMaxUserCount = 6;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
protected shouldShowUpgradeDialogOnInit$: Observable<boolean>;
protected personalPricingTiers$: Observable<PersonalSubscriptionPricingTier[]>;
protected premiumCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected familiesCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
private destroyRef = inject(DestroyRef);
constructor(
private activatedRoute: ActivatedRoute,
private apiService: ApiService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private subscriptionPricingService: DefaultSubscriptionPricingService,
private apiService: ApiService,
private dialogService: DialogService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
const accountCredit$ = this.accountService.activeAccount$.pipe(
mapAccountToSubscriber,
switchMap((account) => this.subscriberBillingClient.getCredit(account)),
this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
this.hasEnoughAccountCredit$ = combineLatest([
accountCredit$,
this.total$,
this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe(
startWith(this.formGroup.value.paymentMethod.type),
),
]).pipe(
map(([credit, total, paymentMethod]) => {
if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) {
return true;
}
return credit >= total;
}),
);
this.accountService.activeAccount$
.pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef))
.subscribe((subscriber) => {
this.subscriber = subscriber;
});
combineLatest([
this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
),
),
this.environmentService.cloudWebVaultUrl$,
])
this.shouldShowNewDesign$ = combineLatest([
this.hasPremiumFromAnyOrganization$,
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
// redirect to user subscription page if they already have premium personally
// redirect to individual vault if they already have premium from an org
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(),
concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => {
takeUntilDestroyed(this.destroyRef),
switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
this.cloudWebVaultURL = cloudWebVaultURL;
if (hasPremiumFromOrg) {
return from(this.navigateToIndividualVault());
}
return of(true);
}),
)
.subscribe();
this.shouldShowUpgradeDialogOnInit$ = combineLatest([
this.hasPremiumFromAnyOrganization$,
this.hasPremiumPersonally$,
this.activatedRoute.queryParams,
]).pipe(
map(([hasOrgPremium, hasPersonalPremium, queryParams]) => {
const cta = queryParams[RouteParams.callToAction];
return !hasOrgPremium && !hasPersonalPremium && cta === RouteParamValues.upgradeToPremium;
}),
);
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.premiumCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
return {
tier,
price:
tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.familiesCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Families);
return {
tier,
price:
tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.shouldShowUpgradeDialogOnInit$
.pipe(
take(1),
switchMap((shouldShowUpgradeDialogOnInit) => {
if (shouldShowUpgradeDialogOnInit) {
return from(this.openUpgradeDialog("Premium"));
}
// Return an Observable that completes immediately when dialog should not be shown
return of(void 0);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
private navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => this.router.navigate(["/vault"]);
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
postFinalizeUpgrade = async () => {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscriptionPage();
};
navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
submitPayment = async (): Promise<void> => {
if (this.formGroup.invalid) {
protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise<void> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
// Check if account credit is selected
const selectedPaymentType = this.formGroup.value.paymentMethod.type;
const selectedPlan =
planType === "Premium"
? PersonalSubscriptionPricingTierIds.Premium
: PersonalSubscriptionPricingTierIds.Families;
let paymentMethodType: number;
let paymentToken: string;
const dialogParams: UnifiedUpgradeDialogParams = {
account,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: selectedPlan,
redirectOnCompletion: true,
};
if (selectedPaymentType === NonTokenizablePaymentMethods.accountCredit) {
// Account credit doesn't need tokenization
paymentMethodType = PaymentMethodType.Credit;
paymentToken = "";
} else {
// Tokenize for card, bank account, or PayPal
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
paymentToken = paymentMethod.token;
}
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: dialogParams,
});
const formData = new FormData();
formData.append("paymentMethodType", paymentMethodType.toString());
formData.append("paymentToken", paymentToken);
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);
await this.apiService.postPremium(formData);
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
dialogRef.closed
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: UnifiedUpgradeDialogResult | undefined) => {
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
void this.finalizeUpgrade();
}
});
}
}

View File

@@ -1,49 +1,88 @@
<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>
<div class="tw-max-w-3xl tw-mx-auto">
<bit-section *ngIf="shouldShowUpgradeView$ | async">
<!-- Free Plan Banner -->
<div class="tw-mt-10 tw-mb-4 tw-text-center">
<span bitBadge variant="secondary" [truncate]="false">
{{ "bitwardenFreeplanMessage" | i18n }}
</span>
</div>
<!-- Main Heading -->
<div class="tw-text-center tw-rounded">
<h1 class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h1>
<p class="tw-text-sm tw-text-muted tw-mb-6 tw-mt-4">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<!-- Already have a subscription section -->
<div class="tw-bg-secondary-100 tw-p-4 tw-rounded-lg tw-border tw-border-secondary-300 tw-mb-6">
<p class="tw-font-semibold tw-mb-0.5">
{{ "alreadyHaveSubscriptionQuestion" | i18n }}
</p>
<p class="tw-text-sm tw-text-muted tw-mb-0.5">
{{ "alreadyHaveSubscriptionSelfHostedMessage" | i18n }}
</p>
<a
bitButton
href="{{ cloudPremiumPageUrl$ | async }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
bitLink
linkType="primary"
(click)="openUploadLicenseDialog()"
class="tw-cursor-pointer tw-text-sm"
>
{{ "purchasePremium" | i18n }}
{{ "uploadYourLicenseFile" | i18n }}
<i class="bwi bwi-angle-right tw-ml-1" aria-hidden="true"></i>
</a>
</bit-callout>
</div>
<!-- Two-Card Layout -->
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-6 tw-mt-6 tw-justify-center">
<!-- Premium Card -->
<div>
<billing-pricing-card
[tagline]="'planDescPremium' | i18n"
[button]="{
type: 'primary',
text: ('upgradeToPremium' | i18n),
icon: { type: 'bwi-external-link', position: 'after' },
}"
[features]="premiumFeatures"
(buttonClick)="onPremiumUpgradeClick()"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "premium" | i18n }}</h3>
</billing-pricing-card>
</div>
<!-- Families Card -->
<div>
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[button]="{
type: 'secondary',
text: ('upgradeToFamilies' | i18n),
icon: { type: 'bwi-external-link', position: 'after' },
}"
[features]="familiesFeatures"
(buttonClick)="onFamiliesUpgradeClick()"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "families" | i18n }}</h3>
</billing-pricing-card>
</div>
</div>
<!-- View all plans Link -->
<div class="tw-text-center tw-mt-6">
<a
bitLink
linkType="primary"
href="https://bitwarden.com/pricing/"
target="_blank"
rel="noopener noreferrer"
>
{{ "viewAllPlans" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-section>
<bit-section>
<individual-self-hosting-license-uploader (onLicenseFileUploaded)="onLicenseFileUploaded()" />
</bit-section>
</bit-container>
</div>

View File

@@ -1,36 +1,61 @@
import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, map, of, switchMap } from "rxjs";
import { firstValueFrom, lastValueFrom, map, Observable, 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";
import {
BadgeModule,
DialogService,
LinkModule,
SectionComponent,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { UpdateLicenseDialogComponent } from "../../shared/update-license-dialog.component";
import { UpdateLicenseDialogResult } from "../../shared/update-license-types";
// 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],
standalone: true,
imports: [
CommonModule,
SectionComponent,
BadgeModule,
TypographyModule,
LinkModule,
I18nPipe,
PricingCardComponent,
],
})
export class SelfHostedPremiumComponent {
cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe(
protected 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),
),
protected cloudFamiliesPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe(
map((url) => `${url}/#/settings/subscription/premium`),
);
hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
protected hasPremiumFromAnyOrganization$: Observable<boolean> =
this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
protected hasPremiumPersonally$: Observable<boolean> = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
@@ -38,42 +63,90 @@ export class SelfHostedPremiumComponent {
),
);
onLicenseFileUploaded = async () => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscription();
};
protected shouldShowUpgradeView$: Observable<boolean> = this.hasPremiumPersonally$.pipe(
map((hasPremium) => !hasPremium),
);
protected premiumFeatures = [
this.i18nService.t("builtInAuthenticator"),
this.i18nService.t("secureFileStorage"),
this.i18nService.t("emergencyAccess"),
this.i18nService.t("breachMonitoring"),
this.i18nService.t("andMoreFeatures"),
];
protected familiesFeatures = [
this.i18nService.t("premiumAccounts"),
this.i18nService.t("familiesUnlimitedSharing"),
this.i18nService.t("familiesUnlimitedCollections"),
this.i18nService.t("familiesSharedStorage"),
];
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private dialogService: DialogService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private router: Router,
private toastService: ToastService,
) {
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
// Redirect premium users to subscription page
this.hasPremiumPersonally$
.pipe(
takeUntilDestroyed(),
switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => {
if (hasPremiumFromAnyOrganization) {
return this.navigateToVault();
}
takeUntilDestroyed(this.destroyRef),
switchMap((hasPremiumPersonally) => {
if (hasPremiumPersonally) {
return this.navigateToSubscription();
}
return of(true);
}),
)
.subscribe();
}
navigateToSubscription = () =>
protected openUploadLicenseDialog = async () => {
const dialogRef = UpdateLicenseDialogComponent.open(this.dialogService);
const result = await lastValueFrom(dialogRef.closed);
if (result === UpdateLicenseDialogResult.Updated) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscription();
}
};
protected navigateToSubscription = async (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
navigateToVault = () => this.router.navigate(["/vault"]);
protected onPremiumUpgradeClick = async () => {
const url = await firstValueFrom(this.cloudPremiumPageUrl$);
if (!url) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("cloudUrlNotConfigured"),
});
return;
}
window.open(url, "_blank", "noopener,noreferrer");
};
protected onFamiliesUpgradeClick = async () => {
const url = await firstValueFrom(this.cloudFamiliesPageUrl$);
if (!url) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("cloudUrlNotConfigured"),
});
return;
}
window.open(url, "_blank", "noopener,noreferrer");
};
}

View File

@@ -1,11 +1,13 @@
<app-header>
<bit-tab-nav-bar slot="tabs" *ngIf="!selfHosted">
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
"subscription" | i18n
}}</bit-tab-link>
<bit-tab-link route="payment-details">{{ "paymentDetails" | i18n }}</bit-tab-link>
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
@if (!selfHosted) {
<bit-tab-nav-bar slot="tabs">
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
"subscription" | i18n
}}</bit-tab-link>
<bit-tab-link route="payment-details">{{ "paymentDetails" | i18n }}</bit-tab-link>
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
}
</app-header>
<router-outlet></router-outlet>

View File

@@ -5,8 +5,6 @@ import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-pro
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/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 { 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";
@@ -26,7 +24,6 @@ import {
describe("UnifiedUpgradePromptService", () => {
let sut: UnifiedUpgradePromptService;
const mockAccountService = mock<AccountService>();
const mockConfigService = mock<ConfigService>();
const mockBillingService = mock<BillingAccountProfileStateService>();
const mockVaultProfileService = mock<VaultProfileService>();
const mockSyncService = mock<SyncService>();
@@ -59,7 +56,6 @@ describe("UnifiedUpgradePromptService", () => {
function setupTestService() {
sut = new UnifiedUpgradePromptService(
mockAccountService,
mockConfigService,
mockBillingService,
mockVaultProfileService,
mockSyncService,
@@ -80,7 +76,6 @@ describe("UnifiedUpgradePromptService", () => {
beforeEach(() => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockStateProvider.getUserState$.mockReturnValue(of(false));
setupTestService();
@@ -96,7 +91,6 @@ describe("UnifiedUpgradePromptService", () => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockDialogOpen.mockReset();
mockReset(mockDialogService);
mockReset(mockConfigService);
mockReset(mockBillingService);
mockReset(mockVaultProfileService);
mockReset(mockSyncService);
@@ -112,11 +106,10 @@ describe("UnifiedUpgradePromptService", () => {
mockStateProvider.getUserState$.mockReturnValue(of(false));
mockStateProvider.setUserState.mockResolvedValue(undefined);
});
it("should subscribe to account and feature flag observables when checking display conditions", async () => {
it("should subscribe to account observables when checking display conditions", async () => {
// Arrange
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
setupTestService();
@@ -125,34 +118,12 @@ describe("UnifiedUpgradePromptService", () => {
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog,
);
expect(mockAccountService.activeAccount$).toBeDefined();
});
it("should not show dialog when feature flag is disabled", async () => {
// Arrange
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has premium", async () => {
// Arrange
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
setupTestService();
@@ -167,7 +138,6 @@ describe("UnifiedUpgradePromptService", () => {
it("should not show dialog when user has any organization membership", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any]));
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
@@ -183,7 +153,6 @@ describe("UnifiedUpgradePromptService", () => {
it("should not show dialog when profile is older than 5 minutes", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const oldDate = new Date();
@@ -202,7 +171,6 @@ describe("UnifiedUpgradePromptService", () => {
it("should show dialog when all conditions are met", async () => {
//Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
@@ -224,7 +192,6 @@ describe("UnifiedUpgradePromptService", () => {
it("should not show dialog when account is null/undefined", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
accountSubject.next(null); // Set account to null
setupTestService();
@@ -238,7 +205,6 @@ describe("UnifiedUpgradePromptService", () => {
it("should not show dialog when profile creation date is unavailable", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null);
@@ -256,7 +222,6 @@ describe("UnifiedUpgradePromptService", () => {
it("should not show dialog when running in self-hosted environment", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const recentDate = new Date();
@@ -275,7 +240,6 @@ describe("UnifiedUpgradePromptService", () => {
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();
@@ -295,7 +259,6 @@ describe("UnifiedUpgradePromptService", () => {
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();
@@ -320,7 +283,6 @@ describe("UnifiedUpgradePromptService", () => {
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();

View File

@@ -6,8 +6,6 @@ import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-pro
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/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 { 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";
@@ -38,7 +36,6 @@ export class UnifiedUpgradePromptService {
private unifiedUpgradeDialogRef: DialogRef<UnifiedUpgradeDialogResult> | null = null;
constructor(
private accountService: AccountService,
private configService: ConfigService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private vaultProfileService: VaultProfileService,
private syncService: SyncService,
@@ -70,26 +67,13 @@ export class UnifiedUpgradePromptService {
isProfileLessThanFiveMinutesOld$,
hasOrganizations$,
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
hasDismissedModal$,
]).pipe(
map(
([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
hasPremium,
isFlagEnabled,
hasDismissed,
]) => {
return (
isProfileLessThanFiveMinutesOld &&
!hasOrganizations &&
!hasPremium &&
isFlagEnabled &&
!hasDismissed
);
},
),
map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, hasDismissed]) => {
return (
isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && !hasDismissed
);
}),
);
}),
take(1),

View File

@@ -1,4 +1,4 @@
import { Component, input, output } from "@angular/core";
import { ChangeDetectionStrategy, 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";
@@ -10,6 +10,7 @@ import {
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
@@ -28,12 +29,11 @@ import {
UnifiedUpgradeDialogStep,
} from "./unified-upgrade-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-upgrade-account",
template: "",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockUpgradeAccountComponent {
readonly dialogTitleMessageOverride = input<string | null>(null);
@@ -42,12 +42,11 @@ class MockUpgradeAccountComponent {
closeClicked = output<UpgradeAccountStatus>();
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-upgrade-payment",
template: "",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockUpgradePaymentComponent {
readonly selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
@@ -65,9 +64,10 @@ describe("UnifiedUpgradeDialogComponent", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const defaultDialogData: UnifiedUpgradeDialogParams = {
@@ -77,10 +77,56 @@ describe("UnifiedUpgradeDialogComponent", () => {
planSelectionStepTitleOverride: null,
};
/**
* Helper function to create and configure a fresh component instance with custom dialog data
*/
async function createComponentWithDialogData(
dialogData: UnifiedUpgradeDialogParams,
waitForStable = false,
): Promise<{
fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
component: UnifiedUpgradeDialogComponent;
}> {
TestBed.resetTestingModule();
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: dialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const newFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const newComponent = newFixture.componentInstance;
newFixture.detectChanges();
if (waitForStable) {
await newFixture.whenStable();
}
return { fixture: newFixture, component: newComponent };
}
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
// Default mock: no premium interest
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
@@ -117,49 +163,63 @@ describe("UnifiedUpgradeDialogComponent", () => {
});
it("should initialize with custom initial step", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
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 { component: customComponent } = await createComponentWithDialogData(customDialogData);
expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
});
describe("ngOnInit premium interest handling", () => {
it("should check premium interest on initialization", async () => {
// Component already initialized in beforeEach
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
});
it("should set hasPremiumInterest signal and clear premium interest when it exists", async () => {
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined);
const { component: customComponent } = await createComponentWithDialogData(
defaultDialogData,
true,
);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(customComponent["hasPremiumInterest"]()).toBe(true);
});
it("should not set hasPremiumInterest signal or clear when premium interest does not exist", async () => {
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
const { component: customComponent } = await createComponentWithDialogData(defaultDialogData);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(customComponent["hasPremiumInterest"]()).toBe(false);
});
});
describe("custom dialog title", () => {
it("should use null as default when no override is provided", () => {
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
});
it("should use custom title when provided in dialog config", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.PlanSelection,
@@ -167,28 +227,7 @@ describe("UnifiedUpgradeDialogComponent", () => {
planSelectionStepTitleOverride: "upgradeYourPlan",
};
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 { component: customComponent } = await createComponentWithDialogData(customDialogData);
expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan");
});
@@ -221,8 +260,6 @@ describe("UnifiedUpgradeDialogComponent", () => {
});
it("should be set to true when provided in dialog config", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: null,
@@ -230,108 +267,32 @@ describe("UnifiedUpgradeDialogComponent", () => {
hideContinueWithoutUpgradingButton: 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 { component: customComponent } = await createComponentWithDialogData(customDialogData);
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 () => {
describe("onComplete", () => {
it("should route to /vault when upgrading to premium with premium interest", async () => {
// Set up component with premium interest
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined);
mockRouter.navigate.mockResolvedValue(true);
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await component["onComplete"](result);
const { component: customComponent } = await createComponentWithDialogData(
defaultDialogData,
true,
);
// Premium interest should be set and cleared during ngOnInit
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();
expect(customComponent["hasPremiumInterest"]()).toBe(true);
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
@@ -340,10 +301,55 @@ describe("UnifiedUpgradeDialogComponent", () => {
await customComponent["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
// Should route to /vault because hasPremiumInterest signal is true
// No additional service calls should be made in onComplete
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit
expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
it("should close dialog when upgrading to families (premium interest not relevant)", async () => {
const result: UpgradePaymentResult = {
status: "upgradedToFamilies",
organizationId: "org-123",
};
await component["onComplete"](result);
// Premium interest logic only runs for premium upgrades, not families
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-123",
});
});
it("should use standard redirect when upgrading to premium without premium interest", async () => {
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
// No premium interest
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
mockRouter.navigate.mockResolvedValue(true);
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
// Verify no premium interest was set during ngOnInit
expect(customComponent["hasPremiumInterest"]()).toBe(false);
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await customComponent["onComplete"](result);
// Should use standard redirect because hasPremiumInterest signal is false
expect(mockRouter.navigate).toHaveBeenCalledWith([
"/settings/subscription/user-subscription",
]);
@@ -354,70 +360,44 @@ describe("UnifiedUpgradeDialogComponent", () => {
});
});
describe("onCloseClicked with premium interest", () => {
it("should clear premium interest when modal is closed", async () => {
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
describe("onCloseClicked", () => {
it("should close dialog without clearing premium interest (cleared in ngOnInit)", async () => {
await component["onCloseClicked"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
// Premium interest should have been cleared only once during ngOnInit, not again here
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("previousStep with premium interest", () => {
it("should NOT clear premium interest when navigating between steps", async () => {
describe("previousStep", () => {
it("should go back to plan selection when on payment step", 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();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0);
});
it("should clear premium interest when backing out of dialog completely", async () => {
TestBed.resetTestingModule();
it("should close dialog when backing out from plan selection step (no premium interest cleared)", async () => {
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
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 { component: customComponent } = await createComponentWithDialogData(customDialogData);
// Start at payment step, go back once to reach plan selection, then go back again to close
await customComponent["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
// Premium interest cleared only in ngOnInit, not in previousStep
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});

View File

@@ -1,6 +1,6 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { ChangeDetectionStrategy, 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";
@@ -63,10 +63,9 @@ export type UnifiedUpgradeDialogParams = {
redirectOnCompletion?: boolean;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-unified-upgrade-dialog",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DialogModule,
@@ -87,6 +86,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
protected readonly account = signal<Account | null>(null);
protected readonly planSelectionStepTitleOverride = signal<string | null>(null);
protected readonly hideContinueWithoutUpgradingButton = signal<boolean>(false);
protected readonly hasPremiumInterest = signal(false);
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
@@ -98,7 +98,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
private premiumInterestStateService: PremiumInterestStateService,
) {}
ngOnInit(): void {
async ngOnInit(): Promise<void> {
this.account.set(this.params.account);
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(this.params.selectedPlan ?? null);
@@ -106,6 +106,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.hideContinueWithoutUpgradingButton.set(
this.params.hideContinueWithoutUpgradingButton ?? false,
);
/*
* Check if the user has premium interest at the point we open the dialog.
* If they do, record it on a component-level signal and clear the user's premium interest.
* This prevents us from having to clear it at every dialog conclusion point.
* */
const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest(
this.params.account.id,
);
if (hasPremiumInterest) {
this.hasPremiumInterest.set(true);
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
}
}
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {
@@ -113,8 +126,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.nextStep();
}
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 });
}
@@ -135,8 +146,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
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 });
}
}
@@ -161,11 +170,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
// 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);
if (this.hasPremiumInterest()) {
await this.router.navigate(["/vault"]);
return; // Exit early, don't use redirectOnCompletion
}

View File

@@ -1,15 +1,15 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
import { Component, computed, DestroyRef, input, OnInit, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { catchError, of } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -32,14 +32,6 @@ export type UpgradeAccountResult = {
plan: PersonalSubscriptionPricingTierId | null;
};
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType };
features: string[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
protected readonly loading = signal(true);
protected premiumCardDetails!: CardDetails;
protected familiesCardDetails!: CardDetails;
protected premiumCardDetails!: SubscriptionPricingCardDetails;
protected familiesCardDetails!: SubscriptionPricingCardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
@@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit {
private createCardDetails(
tier: PersonalSubscriptionPricingTier,
buttonType: ButtonType,
): CardDetails {
): SubscriptionPricingCardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
price: tier.passwordManager.annualPrice
? {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: {
text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",

View File

@@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogRef, DialogService } from "@bitwarden/components";
@@ -32,9 +33,10 @@ describe("UpgradeNavButtonComponent", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
beforeEach(async () => {

View File

@@ -13,6 +13,7 @@ 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 { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
@@ -46,11 +47,12 @@ describe("UpgradePaymentService", () => {
let sut: UpgradePaymentService;
const mockAccount = {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const mockTokenizedPaymentMethod: TokenizedPaymentMethod = {
@@ -151,9 +153,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {
@@ -203,9 +206,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {
@@ -255,9 +259,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
mockAccountService.activeAccount$ = of(mockAccount);
@@ -289,9 +294,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const expectedCredit = 25.5;
@@ -353,9 +359,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {

View File

@@ -200,7 +200,8 @@ export class UpgradePaymentService {
}
private getPasswordManagerSeats(planDetails: PlanDetails): number {
return "users" in planDetails.details.passwordManager
return "users" in planDetails.details.passwordManager &&
planDetails.details.passwordManager.users
? planDetails.details.passwordManager.users
: 0;
}

View File

@@ -50,11 +50,7 @@
</section>
<section>
<billing-cart-summary
#cartSummaryComponent
[passwordManager]="passwordManager()"
[estimatedTax]="estimatedTax$ | async"
></billing-cart-summary>
<billing-cart-summary #cartSummaryComponent [cart]="cart()"></billing-cart-summary>
@if (isFamiliesPlan) {
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}

View File

@@ -9,7 +9,7 @@ import {
signal,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import {
debounceTime,
@@ -22,6 +22,7 @@ import {
combineLatest,
map,
shareReplay,
defer,
} from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
@@ -35,7 +36,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { CartSummaryComponent } from "@bitwarden/pricing";
import { Cart, CartSummaryComponent } from "@bitwarden/pricing";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
@@ -118,23 +119,48 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
protected readonly selectedPlan = signal<PlanDetails | null>(null);
protected readonly loading = signal(true);
protected readonly upgradeToMessage = signal("");
// Cart Summary data
protected readonly passwordManager = computed(() => {
if (!this.selectedPlan()) {
return { name: "", cost: 0, quantity: 0, cadence: "year" as const };
}
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>;
// Use defer to lazily create the observable when subscribed to
protected estimatedTax$ = defer(() =>
this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
switchMap(() => this.refreshSalesTax$()),
),
);
// Convert estimatedTax$ to signal for use in computed cart
protected readonly estimatedTax = toSignal(this.estimatedTax$, {
initialValue: this.INITIAL_TAX_VALUE,
});
// Cart Summary data
protected readonly cart = computed<Cart>(() => {
if (!this.selectedPlan()) {
return {
passwordManager: {
seats: { name: "", cost: 0, quantity: 0 },
},
cadence: "annually",
estimatedTax: 0,
};
}
return {
passwordManager: {
seats: {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0,
quantity: 1,
},
},
cadence: "annually",
estimatedTax: this.estimatedTax() ?? 0,
};
});
constructor(
private i18nService: I18nService,
@@ -186,13 +212,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
});
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
// Only proceed when form has required values
switchMap(() => this.refreshSalesTax$()),
);
this.loading.set(false);
}

View File

@@ -32,11 +32,6 @@
{{ "reinstateSubscription" | i18n }}
</button>
</bit-callout>
<dl *ngIf="selfHosted">
<dt>{{ "expiration" | i18n }}</dt>
<dd *ngIf="sub.expiration">{{ sub.expiration | date: "mediumDate" }}</dd>
<dd *ngIf="!sub.expiration">{{ "neverExpires" | i18n }}</dd>
</dl>
<div class="tw-flex tw-max-w-[1340px] tw-pt-6" *ngIf="!selfHosted">
<div class="tw-flex tw-gap-16 tw-justify-between tw-w-full">
<div class="tw-flex tw-flex-col">
@@ -70,7 +65,7 @@
}}
</span>
<billing-discount-badge
[discount]="getDiscountInfo(sub?.customerDiscount)"
[discount]="getDiscount(sub?.customerDiscount)"
></billing-discount-badge>
</div>
</ng-container>
@@ -97,19 +92,49 @@
</div>
</div>
<ng-container *ngIf="selfHosted">
<div>
<button type="button" bitButton buttonType="secondary" (click)="updateLicense()">
{{ "updateLicense" | i18n }}
</button>
<a
bitButton
buttonType="secondary"
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription"
target="_blank"
rel="noreferrer"
>
{{ "launchCloudSubscription" | i18n }}
</a>
<div class="tw-mt-10 tw-text-center tw-pb-4">
<h1 class="tw-text-4xl tw-my-0">{{ "youHaveBitwardenPremium" | i18n }}</h1>
<div class="tw-text-muted tw-text-xs tw-mb-4 tw-mt-2">
{{ "viewAndManagePremiumSubscription" | i18n }}
</div>
</div>
<div class="tw-flex tw-justify-center">
<bit-base-card class="tw-w-[800px] tw-p-4 sm:tw-p-6">
<div class="tw-flex tw-flex-col tw-gap-5">
<div class="tw-flex tw-items-center tw-justify-between">
<div>
<h2 bitTypography="h2" class="tw-font-semibold tw-mb-0">
{{ "premiumMembership" | i18n }}
</h2>
</div>
<span bitBadge variant="success" *ngIf="isSubscriptionActive">{{
"active" | i18n
}}</span>
</div>
<p bitTypography="body1" class="tw-m-0" *ngIf="sub.expiration">
{{ "youNeedToUpdateLicenseFile" | i18n }}
<strong>{{ sub.expiration | date: "MMMM d, y" }}</strong
>.
</p>
<div class="tw-flex tw-gap-4">
<button type="button" bitButton buttonType="secondary" (click)="updateLicense()">
{{ "updateLicense" | i18n }}
</button>
<a
bitButton
buttonType="secondary"
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription"
target="_blank"
rel="noreferrer"
>
{{ "launchCloudSubscriptionSentenceCase" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</div>
</bit-base-card>
</div>
</ng-container>
<div class="tw-max-w-[1340px]" *ngIf="!selfHosted">

View File

@@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DiscountInfo } from "@bitwarden/pricing";
import { Discount, DiscountTypes, Maybe } from "@bitwarden/pricing";
import {
AdjustStorageDialogComponent,
@@ -159,7 +159,9 @@ export class UserSubscriptionComponent implements OnInit {
if (this.loading) {
return;
}
const dialogRef = UpdateLicenseDialogComponent.open(this.dialogService);
const dialogRef = UpdateLicenseDialogComponent.open(this.dialogService, {
data: { fromUserSubscriptionPage: true },
});
const result = await lastValueFrom(dialogRef.closed);
if (result === UpdateLicenseDialogResult.Updated) {
await this.load();
@@ -249,14 +251,34 @@ export class UserSubscriptionComponent implements OnInit {
}
}
getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null {
getDiscount(discount: BillingCustomerDiscount | null): Maybe<Discount> {
if (!discount) {
return null;
}
return {
active: discount.active,
percentOff: discount.percentOff,
amountOff: discount.amountOff,
};
return discount.amountOff
? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff }
: { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff };
}
get isSubscriptionActive(): boolean {
if (!this.sub) {
return false;
}
if (this.selfHosted) {
return true;
}
const expiration = this.sub.expiration;
if (!expiration || expiration.trim() === "") {
return true;
}
const expirationDate = new Date(expiration);
if (isNaN(expirationDate.getTime())) {
return true;
}
return expirationDate > new Date();
}
}

View File

@@ -387,6 +387,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.focusedIndex = this.selectableProducts.length - 1;
if (!this.isSubscriptionCanceled) {
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
} else {
await this.selectPlan(this.reSubscribablePlan);
}
}
@@ -547,10 +549,28 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return this.selectedPlan.isAnnual ? "year" : "month";
}
get reSubscribablePlan() {
if (!this.currentPlan) {
throw new Error(
"Current plan must be set to find the re-subscribable plan for a cancelled subscription.",
);
}
if (!this.currentPlan.disabled) {
return this.currentPlan;
}
return (
this.passwordManagerPlans.find(
(plan) =>
plan.productTier === this.currentPlan.productTier &&
plan.isAnnual === this.currentPlan.isAnnual &&
!plan.disabled,
) ?? this.currentPlan
);
}
get selectableProducts() {
if (this.isSubscriptionCanceled) {
// Return only the current plan if the subscription is canceled
return [this.currentPlan];
return [this.reSubscribablePlan];
}
if (this.acceptingSponsorship) {
@@ -620,7 +640,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
get storageGb() {
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
return Math.max(
0,
(this.sub?.maxStorageGb ?? 0) - this.selectedPlan.PasswordManager.baseStorageGb,
);
}
passwordManagerSeatTotal(plan: PlanResponse): number {
@@ -644,12 +667,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return 0;
}
return (
plan.PasswordManager.additionalStoragePricePerGb *
// TODO: Eslint upgrade. Please resolve this since the null check does nothing
// eslint-disable-next-line no-constant-binary-expression
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
);
return plan.PasswordManager.additionalStoragePricePerGb * this.storageGb;
}
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {

View File

@@ -104,7 +104,7 @@
<li *ngIf="selectableProduct.PasswordManager.baseStorageGb">
{{
"gbEncryptedFileStorage"
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
| i18n: selectableProduct.PasswordManager.baseStorageGb + " GB"
}}
</li>
<li *ngIf="selectableProduct.hasGroups">
@@ -239,7 +239,7 @@
<bit-hint class="tw-text-sm">{{
"additionalStorageIntervalDesc"
| i18n
: "1 GB"
: `${selectedPlan.PasswordManager.baseStorageGb} GB`
: (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
: ("month" | i18n)
}}</bit-hint>

View File

@@ -654,6 +654,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.singleOrgPolicyBlock) {
return;
}
// Validate billing form for paid plans during creation
if (this.createOrganization && this.selectedPlan.type !== PlanType.Free) {
this.billingFormGroup.markAllAsTouched();
if (this.billingFormGroup.invalid) {
return;
}
}
const doSubmit = async (): Promise<string> => {
let orgId: string;
if (this.createOrganization) {
@@ -703,11 +711,18 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
return orgId;
};
this.formPromise = doSubmit();
const organizationId = await this.formPromise;
this.onSuccess.emit({ organizationId: organizationId });
// TODO: No one actually listening to this message?
this.messagingService.send("organizationCreated", { organizationId });
try {
this.formPromise = doSubmit();
const organizationId = await this.formPromise;
this.onSuccess.emit({ organizationId: organizationId });
// TODO: No one actually listening to this message?
this.messagingService.send("organizationCreated", { organizationId });
} catch (error: unknown) {
if (error instanceof Error && error.message === "Payment method validation failed") {
return;
}
throw error;
}
};
protected get showTaxIdField(): boolean {
@@ -826,6 +841,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
return;
}
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
if (!paymentMethod) {
throw new Error("Payment method validation failed");
}
await this.subscriberBillingClient.updatePaymentMethod(
{ type: "organization", data: this.organization },
paymentMethod,
@@ -877,6 +895,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
if (!paymentMethod) {
throw new Error("Payment method validation failed");
}
const billingAddress = getBillingAddressFromForm(
this.billingFormGroup.controls.billingAddress,

View File

@@ -14,16 +14,18 @@
></app-subscription-status>
<ng-container *ngIf="userOrg.canEditSubscription">
<div class="tw-flex-col">
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>{{ "details" | i18n
}}<span
class="tw-ml-3"
<strong
class="tw-flex tw-items-center tw-gap-3 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-pb-2"
>
{{ "details" | i18n }}
<span
*ngIf="customerDiscount?.percentOff > 0 && !isSecretsManagerTrial()"
bitBadge
variant="success"
class="tw-inline-flex tw-items-center"
>{{ "providerDiscount" | i18n: customerDiscount?.percentOff }}</span
></strong
>
>
</strong>
<bit-table>
<ng-template body>
<ng-container *ngIf="subscription && !userOrg.isFreeOrg">
@@ -40,7 +42,7 @@
<td bitCell class="tw-text-right">
<ng-container
*ngIf="
sub?.customerDiscount?.appliesTo?.includes(i.productId);
isSecretsManagerTrial() && i.productName === 'passwordManager';
else calculateElse
"
>
@@ -49,15 +51,16 @@
<ng-template #calculateElse>
<div class="tw-flex tw-flex-col">
<span>
{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
{{ i.quantity * i.amount | currency: "$" }} /
{{ i.interval | i18n }}
</span>
<span
*ngIf="customerDiscount?.percentOff && !isSecretsManagerTrial()"
*ngIf="
customerDiscount?.percentOff && discountAppliesToProduct(i.productId)
"
class="tw-line-through !tw-text-muted"
>{{
calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$"
}}
/ {{ "year" | i18n }}</span
>{{ i.quantity * i.originalAmount | currency: "$" }} /
{{ "year" | i18n }}</span
>
</div>
</ng-template>

View File

@@ -19,11 +19,9 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -82,9 +80,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private organizationApiService: OrganizationApiServiceAbstraction,
private route: ActivatedRoute,
private dialogService: DialogService,
private configService: ConfigService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
@@ -218,6 +214,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
get subscriptionLineItems() {
return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({
name: lineItem.name,
originalAmount: lineItem.amount,
amount: this.discountPrice(lineItem.amount, lineItem.productId),
quantity: lineItem.quantity,
interval: lineItem.interval,
@@ -403,11 +400,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}
isSecretsManagerTrial(): boolean {
return (
const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone";
const appliesToProduct =
this.sub?.subscription?.items?.some((item) =>
this.sub?.customerDiscount?.appliesTo?.includes(item.productId),
) ?? false
);
this.discountAppliesToProduct(item.productId),
) ?? false;
return isSmStandalone && appliesToProduct;
}
discountAppliesToProduct(productId: string): boolean {
return this.sub?.customerDiscount?.appliesTo?.includes(productId) ?? false;
}
closeChangePlan() {
@@ -436,10 +439,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
await this.load();
}
calculateTotalAppliedDiscount(total: number) {
return total / (1 - this.customerDiscount?.percentOff / 100);
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, {

View File

@@ -29,7 +29,7 @@
<app-display-billing-address
[subscriber]="view.organization"
[billingAddress]="view.billingAddress"
[taxIdWarning]="enableTaxIdWarning ? view.taxIdWarning : null"
[taxIdWarning]="view.taxIdWarning"
(updated)="setBillingAddress($event)"
></app-display-billing-address>

View File

@@ -22,8 +22,6 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { getById } from "@bitwarden/common/platform/misc";
import { DialogService } from "@bitwarden/components";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
@@ -118,12 +116,9 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected enableTaxIdWarning!: boolean;
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private configService: ConfigService,
private dialogService: DialogService,
private messageListener: MessageListener,
private organizationService: OrganizationService,
@@ -140,36 +135,30 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
await this.changePaymentMethod();
}
this.enableTaxIdWarning = await this.configService.getFeatureFlag(
FeatureFlag.PM22415_TaxIDWarnings,
);
if (this.enableTaxIdWarning) {
this.organizationWarningsService.taxIdWarningRefreshed$
.pipe(
switchMap((warning) =>
combineLatest([
of(warning),
this.organization$.pipe(take(1)).pipe(
mapOrganizationToSubscriber,
switchMap((organization) =>
this.subscriberBillingClient.getBillingAddress(organization),
),
this.organizationWarningsService.taxIdWarningRefreshed$
.pipe(
switchMap((warning) =>
combineLatest([
of(warning),
this.organization$.pipe(take(1)).pipe(
mapOrganizationToSubscriber,
switchMap((organization) =>
this.subscriberBillingClient.getBillingAddress(organization),
),
]),
),
takeUntil(this.destroy$),
)
.subscribe(([taxIdWarning, billingAddress]) => {
if (this.viewState$.value) {
this.viewState$.next({
...this.viewState$.value,
taxIdWarning,
billingAddress,
});
}
});
}
),
]),
),
takeUntil(this.destroy$),
)
.subscribe(([taxIdWarning, billingAddress]) => {
if (this.viewState$.value) {
this.viewState$.next({
...this.viewState$.value,
taxIdWarning,
billingAddress,
});
}
});
this.messageListener
.messages$(BANK_ACCOUNT_VERIFIED_COMMAND)
@@ -216,10 +205,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
setBillingAddress = (billingAddress: BillingAddress) => {
if (this.viewState$.value) {
if (
this.enableTaxIdWarning &&
this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId
) {
if (this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId) {
this.organizationWarningsService.refreshTaxIdWarning();
}
this.viewState$.next({

View File

@@ -0,0 +1,232 @@
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import {
BillingCustomerDiscount,
OrganizationSubscriptionResponse,
} from "@bitwarden/common/billing/models/response/organization-subscription.response";
import {
PasswordManagerPlanFeaturesResponse,
PlanResponse,
SecretsManagerPlanFeaturesResponse,
} from "@bitwarden/common/billing/models/response/plan.response";
import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component";
import { PricingSummaryService } from "./pricing-summary.service";
describe("PricingSummaryService", () => {
let service: PricingSummaryService;
beforeEach(() => {
service = new PricingSummaryService();
});
describe("getPricingSummaryData", () => {
let mockPlan: PlanResponse;
let mockSub: OrganizationSubscriptionResponse;
let mockOrganization: Organization;
beforeEach(() => {
// Create mock plan with password manager features
mockPlan = {
productTier: ProductTierType.Teams,
PasswordManager: {
basePrice: 0,
seatPrice: 48,
baseSeats: 0,
hasAdditionalSeatsOption: true,
hasPremiumAccessOption: false,
premiumAccessOptionPrice: 0,
hasAdditionalStorageOption: true,
additionalStoragePricePerGb: 6,
baseStorageGb: 1,
} as PasswordManagerPlanFeaturesResponse,
SecretsManager: {
basePrice: 0,
seatPrice: 72,
baseSeats: 3,
hasAdditionalSeatsOption: true,
hasAdditionalServiceAccountOption: true,
additionalPricePerServiceAccount: 6,
baseServiceAccount: 50,
} as SecretsManagerPlanFeaturesResponse,
} as PlanResponse;
// Create mock subscription
mockSub = {
seats: 5,
smSeats: 5,
smServiceAccounts: 5,
maxStorageGb: 2,
customerDiscount: null,
} as OrganizationSubscriptionResponse;
// Create mock organization
mockOrganization = {
useSecretsManager: false,
} as Organization;
});
it("should calculate pricing data correctly for password manager only", async () => {
const result = await service.getPricingSummaryData(
mockPlan,
mockSub,
mockOrganization,
PlanInterval.Monthly,
false,
50, // estimatedTax
);
expect(result).toEqual<PricingSummaryData>({
selectedPlanInterval: "month",
passwordManagerSeats: 5,
passwordManagerSeatTotal: 240, // 48 * 5
secretsManagerSeatTotal: 360, // 72 * 5
additionalStorageTotal: 6, // 6 * (2 - 1)
additionalStoragePriceMonthly: 6,
additionalServiceAccountTotal: 0, // No additional service accounts (50 base vs 5 used)
totalAppliedDiscount: 0,
secretsManagerSubtotal: 360, // 0 + 360 + 0
passwordManagerSubtotal: 246, // 0 + 240 + 6
total: 296, // 246 + 50 (tax) - organization doesn't use secrets manager
organization: mockOrganization,
sub: mockSub,
selectedPlan: mockPlan,
selectedInterval: PlanInterval.Monthly,
discountPercentageFromSub: 0,
discountPercentage: 20,
acceptingSponsorship: false,
additionalServiceAccount: 0, // 50 - 5 = 45, which is > 0, so return 0
storageGb: 1,
isSecretsManagerTrial: false,
estimatedTax: 50,
});
});
it("should calculate pricing data correctly with secrets manager enabled", async () => {
mockOrganization.useSecretsManager = true;
const result = await service.getPricingSummaryData(
mockPlan,
mockSub,
mockOrganization,
PlanInterval.Monthly,
false,
50,
);
expect(result.total).toBe(656); // passwordManagerSubtotal (246) + secretsManagerSubtotal (360) + tax (50)
});
it("should handle secrets manager trial", async () => {
const result = await service.getPricingSummaryData(
mockPlan,
mockSub,
mockOrganization,
PlanInterval.Monthly,
true, // isSecretsManagerTrial
50,
);
expect(result.passwordManagerSeatTotal).toBe(0); // Should be 0 during trial
expect(result.discountPercentageFromSub).toBe(0); // Should be 0 during trial
});
it("should handle premium access option", async () => {
mockPlan.PasswordManager.hasPremiumAccessOption = true;
mockPlan.PasswordManager.premiumAccessOptionPrice = 25;
const result = await service.getPricingSummaryData(
mockPlan,
mockSub,
mockOrganization,
PlanInterval.Monthly,
false,
50,
);
expect(result.passwordManagerSubtotal).toBe(271); // 0 + 240 + 6 + 25
});
it("should handle customer discount", async () => {
mockSub.customerDiscount = {
id: "discount1",
active: true,
percentOff: 10,
appliesTo: ["subscription"],
} as BillingCustomerDiscount;
const result = await service.getPricingSummaryData(
mockPlan,
mockSub,
mockOrganization,
PlanInterval.Monthly,
false,
50,
);
expect(result.discountPercentageFromSub).toBe(10);
});
it("should handle zero storage calculation", async () => {
mockSub.maxStorageGb = 1; // Same as base storage
const result = await service.getPricingSummaryData(
mockPlan,
mockSub,
mockOrganization,
PlanInterval.Monthly,
false,
50,
);
expect(result.additionalStorageTotal).toBe(0);
expect(result.storageGb).toBe(0);
});
});
describe("getAdditionalServiceAccount", () => {
let mockPlan: PlanResponse;
let mockSub: OrganizationSubscriptionResponse;
beforeEach(() => {
mockPlan = {
SecretsManager: {
baseServiceAccount: 50,
} as SecretsManagerPlanFeaturesResponse,
} as PlanResponse;
mockSub = {
smServiceAccounts: 55,
} as OrganizationSubscriptionResponse;
});
it("should return additional service accounts when used exceeds base", () => {
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
expect(result).toBe(5); // Math.abs(50 - 55) = 5
});
it("should return 0 when used is less than or equal to base", () => {
mockSub.smServiceAccounts = 40;
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
expect(result).toBe(0);
});
it("should return 0 when used equals base", () => {
mockSub.smServiceAccounts = 50;
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
expect(result).toBe(0);
});
it("should return 0 when plan is null", () => {
const result = service.getAdditionalServiceAccount(null, mockSub);
expect(result).toBe(0);
});
it("should return 0 when plan has no SecretsManager", () => {
mockPlan.SecretsManager = null;
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
expect(result).toBe(0);
});
});
});

View File

@@ -31,9 +31,10 @@ export class PricingSummaryService {
const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub);
const storageGb = Math.max(0, (sub?.maxStorageGb ?? 0) - plan.PasswordManager.baseStorageGb);
const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption
? plan.PasswordManager.additionalStoragePricePerGb *
(sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0)
? plan.PasswordManager.additionalStoragePricePerGb * storageGb
: 0;
const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0;
@@ -66,7 +67,6 @@ export class PricingSummaryService {
: (sub?.customerDiscount?.percentOff ?? 0);
const discountPercentage = 20;
const acceptingSponsorship = false;
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
const total = organization?.useSecretsManager
? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax

View File

@@ -1,16 +1,30 @@
<form [formGroup]="updateLicenseForm" [bitSubmit]="submitLicenseDialog">
<bit-dialog dialogSize="default" [title]="'updateLicense' | i18n">
<bit-dialog
dialogSize="default"
[title]="(fromUserSubscriptionPage ? 'uploadLicense' : 'uploadLicenseFile') | i18n"
>
<ng-container bitDialogContent>
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
<p class="tw-mb-4">{{ "uploadLicenseFileDesc" | i18n: "bitwarden_license.json" }}</p>
<div class="tw-mb-4">
<label class="tw-block tw-text-sm tw-text-muted tw-mb-2">{{
(fromUserSubscriptionPage ? "uploadYourPremiumLicenseFile" : "uploadYourLicenseFile")
| i18n
}}</label>
<div class="tw-mb-2">
<button
bitButton
type="button"
buttonType="unstyled"
class="tw-text-primary-600 tw-p-0 tw-border-0 tw-bg-transparent hover:tw-underline tw-cursor-pointer"
(click)="fileSelector.click()"
>
{{ "chooseFile" | i18n }}
</button>
{{ licenseFile ? licenseFile.name : ("noFileChosen" | i18n) }}
<span class="tw-ml-2 tw-text-muted">{{
licenseFile ? licenseFile.name : ("noFileChosen" | i18n)
}}</span>
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
@@ -18,12 +32,12 @@
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<p class="tw-text-sm tw-text-muted">{{ "maxFileSizeSansPunctuation" | i18n }}</p>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
<button type="submit" buttonType="primary" bitButton bitFormButton [disabled]="!licenseFile">
{{ "upload" | i18n }}
</button>
<button
bitButton

View File

@@ -1,15 +1,28 @@
import { Component } from "@angular/core";
import { Component, Inject } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { UpdateLicenseDialogResult } from "./update-license-types";
import { UpdateLicenseComponent } from "./update-license.component";
export interface UpdateLicenseDialogData {
fromUserSubscriptionPage?: boolean;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -17,6 +30,8 @@ import { UpdateLicenseComponent } from "./update-license.component";
standalone: false,
})
export class UpdateLicenseDialogComponent extends UpdateLicenseComponent {
fromUserSubscriptionPage: boolean;
constructor(
private dialogRef: DialogRef,
apiService: ApiService,
@@ -25,6 +40,9 @@ export class UpdateLicenseDialogComponent extends UpdateLicenseComponent {
organizationApiService: OrganizationApiServiceAbstraction,
formBuilder: FormBuilder,
toastService: ToastService,
private accountService: AccountService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
@Inject(DIALOG_DATA) private dialogData: UpdateLicenseDialogData = {},
) {
super(
apiService,
@@ -34,10 +52,25 @@ export class UpdateLicenseDialogComponent extends UpdateLicenseComponent {
formBuilder,
toastService,
);
this.fromUserSubscriptionPage = dialogData?.fromUserSubscriptionPage ?? false;
}
async submitLicense() {
const result = await this.submit();
if (result === UpdateLicenseDialogResult.Updated) {
// Update billing state after successful upload (only for personal licenses)
if (this.organizationId == null) {
const account: Account | null = await firstValueFrom(this.accountService.activeAccount$);
if (account) {
const hasPremiumFromAnyOrganization = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
);
await this.billingAccountProfileStateService.setHasPremium(
true,
hasPremiumFromAnyOrganization,
account.id,
);
}
}
this.dialogRef.close(UpdateLicenseDialogResult.Updated);
}
}
@@ -47,10 +80,10 @@ export class UpdateLicenseDialogComponent extends UpdateLicenseComponent {
};
cancel = async () => {
await this.cancel();
this.onCanceled.emit();
this.dialogRef.close(UpdateLicenseDialogResult.Cancelled);
};
static open(dialogService: DialogService) {
return dialogService.open<UpdateLicenseDialogResult>(UpdateLicenseDialogComponent);
static open(dialogService: DialogService, config?: DialogConfig<UpdateLicenseDialogData>) {
return dialogService.open<UpdateLicenseDialogResult>(UpdateLicenseDialogComponent, config);
}
}

View File

@@ -12,8 +12,6 @@ import {
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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 { BannerModule, DialogService } from "@bitwarden/components";
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
@@ -88,23 +86,21 @@ type GetWarning$ = () => Observable<TaxIdWarningType | null>;
@Component({
selector: "app-tax-id-warning",
template: `
@if (enableTaxIdWarning$ | async) {
@let view = view$ | async;
@let view = view$ | async;
@if (view) {
<bit-banner id="tax-id-warning-banner" bannerType="warning" (onClose)="trackDismissal()">
{{ view.message }}
<a
bitLink
linkType="secondary"
(click)="editBillingAddress()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ view.callToAction }}
</a>
</bit-banner>
}
@if (view) {
<bit-banner id="tax-id-warning-banner" bannerType="warning" (onClose)="trackDismissal()">
{{ view.message }}
<a
bitLink
linkType="secondary"
(click)="editBillingAddress()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ view.callToAction }}
</a>
</bit-banner>
}
`,
imports: [BannerModule, SharedModule],
@@ -120,10 +116,6 @@ export class TaxIdWarningComponent implements OnInit {
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() billingAddressUpdated = new EventEmitter<void>();
protected enableTaxIdWarning$ = this.configService.getFeatureFlag$(
FeatureFlag.PM22415_TaxIDWarnings,
);
protected userId$ = this.accountService.activeAccount$.pipe(
filter((account): account is Account => account !== null),
getUserId,
@@ -209,7 +201,6 @@ export class TaxIdWarningComponent implements OnInit {
constructor(
private accountService: AccountService,
private configService: ConfigService,
private dialogService: DialogService,
private i18nService: I18nService,
private subscriberBillingClient: SubscriberBillingClient,

View File

@@ -9,8 +9,6 @@ import {
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
OrganizationUserService,
DefaultOrganizationUserService,
} from "@bitwarden/admin-console/common";
@@ -46,6 +44,10 @@ import {
InternalUserDecryptionOptionsServiceAbstraction,
LoginEmailService,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@@ -59,16 +61,20 @@ import {
} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
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 { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutStringType,
@@ -111,6 +117,7 @@ import {
} from "@bitwarden/common/platform/theming/theme-state.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
KdfConfigService,
@@ -124,7 +131,6 @@ import {
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";
@@ -149,6 +155,7 @@ import { WebFileDownloadService } from "../core/web-file-download.service";
import { UserKeyRotationService } from "../key-management/key-rotation/user-key-rotation.service";
import { WebLockComponentService } from "../key-management/lock/services/web-lock-component.service";
import { WebProcessReloadService } from "../key-management/services/web-process-reload.service";
import { WebSessionTimeoutTypeService } from "../key-management/session-timeout/services/web-session-timeout-type.service";
import { WebBiometricsService } from "../key-management/web-biometric.service";
import { WebIpcService } from "../platform/ipc/web-ipc.service";
import { WebEnvironmentService } from "../platform/web-environment.service";
@@ -312,6 +319,7 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
OrganizationInviteService,
RouterService,
AccountCryptographicStateService,
],
}),
safeProvider({
@@ -370,6 +378,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({
@@ -469,16 +478,26 @@ const safeProviders: SafeProvider[] = [
useClass: WebSystemService,
deps: [],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: WebSessionTimeoutTypeService,
deps: [PlatformUtilsService],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: WebSessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, PlatformUtilsService],
useClass: SessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService],
}),
safeProvider({
provide: AuthRequestAnsweringService,
useClass: NoopAuthRequestAnsweringService,
deps: [],
}),
];
@NgModule({
declarations: [],
imports: [CommonModule, JslibServicesModule],
imports: [CommonModule, JslibServicesModule, GeneratorServicesModule],
// Do not register your dependency here! Add it to the typesafeProviders array using the helper function
providers: safeProviders,
})

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