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:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -587,6 +587,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText },
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
state: {
|
||||
focusMainAfterNav: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./members.module";
|
||||
export * from "./pipes";
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-status.pipe";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./member.export";
|
||||
export * from "./member-export.service";
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { action } from "storybook/actions";
|
||||
|
||||
import { AccessItemType, AccessItemView } from "./access-selector.models";
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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$;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ×
|
||||
{{ 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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user