mirror of
https://github.com/bitwarden/browser
synced 2026-03-02 11:31:44 +00:00
Merge branch 'main' into uif/CL-854/page-header-web-usage
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
# Bitwarden Web App
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-vault.png" alt="" width="600" height="358" />
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/web-vault",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.1",
|
||||
"scripts": {
|
||||
"build:oss": "webpack",
|
||||
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]="'import' | i18n"
|
||||
route="settings/tools/import"
|
||||
*ngIf="organization.canAccessImport"
|
||||
></bit-nav-item>
|
||||
<bit-nav-item
|
||||
[text]="'exportVault' | i18n"
|
||||
[text]="'export' | 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";
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ 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 { 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,7 +46,11 @@ 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";
|
||||
@@ -70,7 +76,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 +119,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
private policyService: PolicyService,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
@@ -126,6 +134,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 +366,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 +378,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 +397,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 +408,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 +443,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 +492,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);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { of } from "rxjs";
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
OrganizationUserType,
|
||||
@@ -22,9 +23,8 @@ 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;
|
||||
@@ -308,41 +308,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 +694,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 +702,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,
|
||||
|
||||
@@ -20,9 +20,12 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
|
||||
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;
|
||||
@@ -162,20 +165,36 @@ export class MemberActionsService {
|
||||
}
|
||||
}
|
||||
|
||||
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 +226,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,498 @@
|
||||
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$", () => {
|
||||
it("should fetch policies from API for current organization", async () => {
|
||||
const mockPolicyResponsesData = [
|
||||
{
|
||||
id: newGuid(),
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
enabled: true,
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
id: newGuid(),
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.RequireSso,
|
||||
enabled: false,
|
||||
data: null,
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = new ListResponse(
|
||||
{ Data: mockPolicyResponsesData, ContinuationToken: null },
|
||||
PolicyResponse,
|
||||
);
|
||||
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
|
||||
|
||||
const policies = await firstValueFrom(component["orgPolicies$"]);
|
||||
expect(policies).toEqual(listResponse.data);
|
||||
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
|
||||
});
|
||||
|
||||
it("should return empty array when API returns no data", async () => {
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
const policies = await firstValueFrom(component["orgPolicies$"]);
|
||||
expect(policies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when API returns null data", async () => {
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
const policies = await firstValueFrom(component["orgPolicies$"]);
|
||||
expect(policies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policiesEnabledMap$", () => {
|
||||
it("should create a map of policy types to their enabled status", async () => {
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse(
|
||||
{ Data: mockPolicyResponsesData, ContinuationToken: null },
|
||||
PolicyResponse,
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("should create empty map when no policies exist", async () => {
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("should refresh policies when policyService emits", async () => {
|
||||
const policiesSubject = new BehaviorSubject<any[]>([]);
|
||||
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
|
||||
|
||||
let callCount = 0;
|
||||
mockPolicyApiService.getPolicies.mockImplementation(() => {
|
||||
callCount++;
|
||||
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
|
||||
});
|
||||
|
||||
const newFixture = TestBed.createComponent(PoliciesComponent);
|
||||
newFixture.detectChanges();
|
||||
|
||||
const initialCallCount = callCount;
|
||||
|
||||
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
|
||||
|
||||
expect(callCount).toBeGreaterThan(initialCallCount);
|
||||
|
||||
newFixture.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleLaunchEvent", () => {
|
||||
it("should open policy dialog when policyId is in query params", async () => {
|
||||
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,
|
||||
};
|
||||
|
||||
queryParamsSubject.next({ policyId: mockPolicyId });
|
||||
|
||||
mockPolicyApiService.getPolicies.mockReturnValue(
|
||||
of(
|
||||
new ListResponse(
|
||||
{ Data: [mockPolicyResponseData], ContinuationToken: null },
|
||||
PolicyResponse,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const 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();
|
||||
|
||||
const newFixture = TestBed.createComponent(PoliciesComponent);
|
||||
newFixture.detectChanges();
|
||||
|
||||
expect(dialogOpenSpy).toHaveBeenCalled();
|
||||
const callArgs = dialogOpenSpy.mock.calls[0][1];
|
||||
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
|
||||
expect(callArgs.data?.organizationId).toBe(mockOrgId);
|
||||
|
||||
newFixture.destroy();
|
||||
});
|
||||
|
||||
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 } 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,53 @@ 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 : [])),
|
||||
);
|
||||
|
||||
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 +91,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 +120,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,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -22,10 +22,10 @@ export class OrganizationDataOwnershipPolicy 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: "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,4 +1,4 @@
|
||||
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { lastValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -34,11 +34,11 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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: "import",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,7 +68,7 @@ const routes: Routes = [
|
||||
),
|
||||
canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)],
|
||||
data: {
|
||||
titleId: "exportVault",
|
||||
titleId: "export",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div>
|
||||
@if (premiumCardData$ | async; as premiumData) {
|
||||
<billing-pricing-card
|
||||
[tagline]="'planDescPremium' | i18n"
|
||||
[tagline]="'advancedOnlineSecurity' | i18n"
|
||||
[price]="{ amount: premiumData.price, cadence: 'monthly' }"
|
||||
[button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }"
|
||||
[features]="premiumData.features"
|
||||
|
||||
@@ -157,7 +157,7 @@ export class CloudHostedPremiumVNextComponent {
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "standalone"
|
||||
tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
@@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent {
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "packaged"
|
||||
tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
@@ -82,7 +82,10 @@
|
||||
/>
|
||||
<bit-hint>{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
|
||||
| i18n
|
||||
: `${(providedStorageGb$ | async)} GB`
|
||||
: (storagePrice$ | async | currency: "$")
|
||||
: ("year" | i18n)
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,8 @@ 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 { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
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";
|
||||
@@ -75,6 +75,7 @@ export class CloudHostedPremiumComponent {
|
||||
return {
|
||||
seat: premiumPlan.passwordManager.annualPrice,
|
||||
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
|
||||
providedStorageGb: premiumPlan.passwordManager.providedStorageGB,
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
@@ -84,6 +85,8 @@ export class CloudHostedPremiumComponent {
|
||||
|
||||
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
|
||||
|
||||
providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb));
|
||||
|
||||
protected isLoadingPrices$ = this.premiumPrices$.pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
@@ -134,7 +137,7 @@ export class CloudHostedPremiumComponent {
|
||||
private accountService: AccountService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
private subscriptionPricingService: DefaultSubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
) {
|
||||
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -620,7 +620,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 +647,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">
|
||||
@@ -38,12 +40,7 @@
|
||||
{{ i.amount | currency: "$" }}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<ng-container
|
||||
*ngIf="
|
||||
sub?.customerDiscount?.appliesTo?.includes(i.productId);
|
||||
else calculateElse
|
||||
"
|
||||
>
|
||||
<ng-container *ngIf="isSecretsManagerTrial(); else calculateElse">
|
||||
{{ "freeForOneYear" | i18n }}
|
||||
</ng-container>
|
||||
<ng-template #calculateElse>
|
||||
@@ -52,7 +49,7 @@
|
||||
{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
|
||||
</span>
|
||||
<span
|
||||
*ngIf="customerDiscount?.percentOff && !isSecretsManagerTrial()"
|
||||
*ngIf="customerDiscount?.percentOff"
|
||||
class="tw-line-through !tw-text-muted"
|
||||
>{{
|
||||
calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$"
|
||||
|
||||
@@ -403,11 +403,13 @@ 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
|
||||
);
|
||||
) ?? false;
|
||||
|
||||
return isSmStandalone && appliesToProduct;
|
||||
}
|
||||
|
||||
closeChangePlan() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,7 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen
|
||||
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 +112,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 +126,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 +150,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";
|
||||
@@ -469,16 +471,21 @@ 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],
|
||||
}),
|
||||
];
|
||||
|
||||
@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,
|
||||
})
|
||||
|
||||
@@ -372,7 +372,7 @@ export class EventService {
|
||||
msg = humanReadableMsg = this.i18nService.t("enabledSso");
|
||||
break;
|
||||
case EventType.Organization_DisabledSso:
|
||||
msg = humanReadableMsg = this.i18nService.t("disabledSso");
|
||||
msg = humanReadableMsg = this.i18nService.t("ssoTurnedOff");
|
||||
break;
|
||||
case EventType.Organization_EnabledKeyConnector:
|
||||
msg = humanReadableMsg = this.i18nService.t("enabledKeyConnector");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
|
||||
@@ -219,4 +219,8 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
|
||||
getAutofillKeyboardShortcut(): Promise<string> {
|
||||
return null;
|
||||
}
|
||||
|
||||
packageType(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BreachAccountResponse } from "@bitwarden/common/dirt/models/response/breach-account.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { BreachReportComponent } from "./breach-report.component";
|
||||
@@ -38,9 +39,10 @@ describe("BreachReportComponent", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
|
||||
id: "testId" as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
...mockAccountInfoWith({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs";
|
||||
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 { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "dataRecoveryTitle" | i18n }}</h2>
|
||||
|
||||
<div class="tw-max-w-lg">
|
||||
<p bitTypography="body1" class="tw-mb-4">
|
||||
{{ "dataRecoveryDescription" | i18n }}
|
||||
</p>
|
||||
|
||||
@if (!diagnosticsCompleted() && !recoveryCompleted()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[bitAction]="runDiagnostics"
|
||||
class="tw-mb-6"
|
||||
>
|
||||
{{ "runDiagnostics" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="tw-space-y-3 tw-mb-6">
|
||||
@for (step of steps(); track $index) {
|
||||
@if (
|
||||
($index === 0 && hasStarted()) ||
|
||||
($index > 0 &&
|
||||
(steps()[$index - 1].status === StepStatus.Completed ||
|
||||
steps()[$index - 1].status === StepStatus.Failed))
|
||||
) {
|
||||
<div class="tw-flex tw-items-start tw-gap-3">
|
||||
<div class="tw-mt-1">
|
||||
@if (step.status === StepStatus.Failed) {
|
||||
<i class="bwi bwi-close tw-text-danger" aria-hidden="true"></i>
|
||||
} @else if (step.status === StepStatus.Completed) {
|
||||
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
|
||||
} @else if (step.status === StepStatus.InProgress) {
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
|
||||
} @else {
|
||||
<i class="bwi bwi-circle tw-text-secondary-300" aria-hidden="true"></i>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
[class.tw-text-danger]="step.status === StepStatus.Failed"
|
||||
[class.tw-text-success]="step.status === StepStatus.Completed"
|
||||
[class.tw-text-primary-600]="step.status === StepStatus.InProgress"
|
||||
[class.tw-font-semibold]="step.status === StepStatus.InProgress"
|
||||
[class.tw-text-secondary-500]="step.status === StepStatus.NotStarted"
|
||||
>
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (diagnosticsCompleted()) {
|
||||
<div class="tw-flex tw-gap-3">
|
||||
@if (hasIssues() && !recoveryCompleted()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="status() === StepStatus.InProgress"
|
||||
[bitAction]="runRecovery"
|
||||
>
|
||||
{{ "repairIssues" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="saveDiagnosticLogs">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "saveDiagnosticLogs" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,348 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { DataRecoveryComponent, StepStatus } from "./data-recovery.component";
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./steps";
|
||||
|
||||
// Mock SdkLoadService
|
||||
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({
|
||||
SdkLoadService: {
|
||||
Ready: Promise.resolve(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("DataRecoveryComponent", () => {
|
||||
let component: DataRecoveryComponent;
|
||||
let fixture: ComponentFixture<DataRecoveryComponent>;
|
||||
|
||||
// Mock Services
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
let mockFolderApiService: MockProxy<FolderApiServiceAbstraction>;
|
||||
let mockCipherEncryptService: MockProxy<CipherEncryptionService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockPrivateKeyRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let mockFileDownloadService: MockProxy<FileDownloadService>;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockApiService = mock<ApiService>();
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
mockKeyService = mock<KeyService>();
|
||||
mockFolderApiService = mock<FolderApiServiceAbstraction>();
|
||||
mockCipherEncryptService = mock<CipherEncryptionService>();
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockPrivateKeyRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockCryptoFunctionService = mock<CryptoFunctionService>();
|
||||
mockFileDownloadService = mock<FileDownloadService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DataRecoveryComponent],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: FolderApiServiceAbstraction, useValue: mockFolderApiService },
|
||||
{ provide: CipherEncryptionService, useValue: mockCipherEncryptService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{
|
||||
provide: UserAsymmetricKeysRegenerationService,
|
||||
useValue: mockPrivateKeyRegenerationService,
|
||||
},
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DataRecoveryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("Component Initialization", () => {
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with default signal values", () => {
|
||||
expect(component.status()).toBe(StepStatus.NotStarted);
|
||||
expect(component.hasStarted()).toBe(false);
|
||||
expect(component.diagnosticsCompleted()).toBe(false);
|
||||
expect(component.recoveryCompleted()).toBe(false);
|
||||
expect(component.hasIssues()).toBe(false);
|
||||
});
|
||||
|
||||
it("should initialize steps in correct order", () => {
|
||||
const steps = component.steps();
|
||||
expect(steps.length).toBe(5);
|
||||
expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n");
|
||||
expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n");
|
||||
expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n");
|
||||
expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n");
|
||||
expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDiagnostics", () => {
|
||||
let mockSteps: MockProxy<RecoveryStep>[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock steps
|
||||
mockSteps = Array(5)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
const mockStep = mock<RecoveryStep>();
|
||||
mockStep.title = "mockStep";
|
||||
mockStep.runDiagnostics.mockResolvedValue(true);
|
||||
mockStep.canRecover.mockReturnValue(false);
|
||||
return mockStep;
|
||||
});
|
||||
|
||||
// Replace recovery steps with mocks
|
||||
component["recoverySteps"] = mockSteps;
|
||||
});
|
||||
|
||||
it("should not run if already running", async () => {
|
||||
component["status"].set(StepStatus.InProgress);
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set hasStarted, isRunning and initialize workingData", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.hasStarted()).toBe(true);
|
||||
expect(component["workingData"]).toBeDefined();
|
||||
expect(component["workingData"]?.userId).toBeNull();
|
||||
expect(component["workingData"]?.userKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should run diagnostics for all steps", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
mockSteps.forEach((step) => {
|
||||
expect(step.runDiagnostics).toHaveBeenCalledWith(
|
||||
component["workingData"],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark steps as completed when diagnostics succeed", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
const steps = component.steps();
|
||||
steps.forEach((step) => {
|
||||
expect(step.status).toBe(StepStatus.Completed);
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark steps as failed when diagnostics return false", async () => {
|
||||
mockSteps[2].runDiagnostics.mockResolvedValue(false);
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
const steps = component.steps();
|
||||
expect(steps[2].status).toBe(StepStatus.Failed);
|
||||
});
|
||||
|
||||
it("should mark steps as failed when diagnostics throw error", async () => {
|
||||
mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error"));
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
const steps = component.steps();
|
||||
expect(steps[3].status).toBe(StepStatus.Failed);
|
||||
expect(steps[3].message).toBe("Test error");
|
||||
});
|
||||
|
||||
it("should continue diagnostics even if a step fails", async () => {
|
||||
mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed"));
|
||||
mockSteps[3].runDiagnostics.mockResolvedValue(false);
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
// All steps should have been called despite failures
|
||||
mockSteps.forEach((step) => {
|
||||
expect(step.runDiagnostics).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set hasIssues to true when a step can recover", async () => {
|
||||
mockSteps[2].runDiagnostics.mockResolvedValue(false);
|
||||
mockSteps[2].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.hasIssues()).toBe(true);
|
||||
});
|
||||
|
||||
it("should set hasIssues to false when no step can recover", async () => {
|
||||
mockSteps.forEach((step) => {
|
||||
step.runDiagnostics.mockResolvedValue(true);
|
||||
step.canRecover.mockReturnValue(false);
|
||||
});
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.hasIssues()).toBe(false);
|
||||
});
|
||||
|
||||
it("should set diagnosticsCompleted and status to completed when complete", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.diagnosticsCompleted()).toBe(true);
|
||||
expect(component.status()).toBe(StepStatus.Completed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRecovery", () => {
|
||||
let mockSteps: MockProxy<RecoveryStep>[];
|
||||
let mockWorkingData: RecoveryWorkingData;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWorkingData = {
|
||||
userId: mockUserId,
|
||||
userKey: null as any,
|
||||
isPrivateKeyCorrupt: false,
|
||||
encryptedPrivateKey: null,
|
||||
ciphers: [],
|
||||
folders: [],
|
||||
};
|
||||
|
||||
mockSteps = Array(5)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
const mockStep = mock<RecoveryStep>();
|
||||
mockStep.title = "mockStep";
|
||||
mockStep.canRecover.mockReturnValue(false);
|
||||
mockStep.runRecovery.mockResolvedValue();
|
||||
mockStep.runDiagnostics.mockResolvedValue(true);
|
||||
return mockStep;
|
||||
});
|
||||
|
||||
component["recoverySteps"] = mockSteps;
|
||||
component["workingData"] = mockWorkingData;
|
||||
});
|
||||
|
||||
it("should not run if already running", async () => {
|
||||
component["status"].set(StepStatus.InProgress);
|
||||
await component.runRecovery();
|
||||
|
||||
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not run if workingData is null", async () => {
|
||||
component["workingData"] = null;
|
||||
await component.runRecovery();
|
||||
|
||||
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only run recovery for steps that can recover", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[3].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
|
||||
expect(mockSteps[1].runRecovery).toHaveBeenCalled();
|
||||
expect(mockSteps[2].runRecovery).not.toHaveBeenCalled();
|
||||
expect(mockSteps[3].runRecovery).toHaveBeenCalled();
|
||||
expect(mockSteps[4].runRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set recoveryCompleted and status when successful", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(component.recoveryCompleted()).toBe(true);
|
||||
expect(component.status()).toBe(StepStatus.Completed);
|
||||
});
|
||||
|
||||
it("should set status to failed if recovery is cancelled", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled"));
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(component.status()).toBe(StepStatus.Failed);
|
||||
expect(component.recoveryCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it("should re-run diagnostics after recovery completes", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
// Diagnostics should be called twice: once for initial diagnostic scan
|
||||
mockSteps.forEach((step) => {
|
||||
expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
it("should update hasIssues after re-running diagnostics", async () => {
|
||||
// Setup initial state with an issue
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[1].runDiagnostics.mockResolvedValue(false);
|
||||
|
||||
// After recovery completes, the issue should be fixed
|
||||
mockSteps[1].runRecovery.mockImplementation(() => {
|
||||
// Simulate recovery fixing the issue
|
||||
mockSteps[1].canRecover.mockReturnValue(false);
|
||||
mockSteps[1].runDiagnostics.mockResolvedValue(true);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
// Verify hasIssues is updated after re-running diagnostics
|
||||
expect(component.hasIssues()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveDiagnosticLogs", () => {
|
||||
it("should call fileDownloadService with log content", () => {
|
||||
component.saveDiagnosticLogs();
|
||||
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
|
||||
fileName: expect.stringContaining("data-recovery-logs-"),
|
||||
blobData: expect.any(String),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should include timestamp in filename", () => {
|
||||
component.saveDiagnosticLogs();
|
||||
|
||||
const downloadCall = mockFileDownloadService.download.mock.calls[0][0];
|
||||
expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { ButtonModule, DialogService } from "@bitwarden/components";
|
||||
import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { LogRecorder } from "./log-recorder";
|
||||
import {
|
||||
SyncStep,
|
||||
UserInfoStep,
|
||||
RecoveryStep,
|
||||
PrivateKeyStep,
|
||||
RecoveryWorkingData,
|
||||
FolderStep,
|
||||
CipherStep,
|
||||
} from "./steps";
|
||||
|
||||
export const StepStatus = Object.freeze({
|
||||
NotStarted: 0,
|
||||
InProgress: 1,
|
||||
Completed: 2,
|
||||
Failed: 3,
|
||||
} as const);
|
||||
export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus];
|
||||
|
||||
interface StepState {
|
||||
title: string;
|
||||
status: StepStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-data-recovery",
|
||||
templateUrl: "data-recovery.component.html",
|
||||
standalone: true,
|
||||
imports: [JslibModule, ButtonModule, CommonModule, SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DataRecoveryComponent {
|
||||
protected readonly StepStatus = StepStatus;
|
||||
|
||||
private i18nService = inject(I18nService);
|
||||
private apiService = inject(ApiService);
|
||||
private accountService = inject(AccountService);
|
||||
private keyService = inject(KeyService);
|
||||
private folderApiService = inject(FolderApiServiceAbstraction);
|
||||
private cipherEncryptService = inject(CipherEncryptionService);
|
||||
private dialogService = inject(DialogService);
|
||||
private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService);
|
||||
private cryptoFunctionService = inject(CryptoFunctionService);
|
||||
private logService = inject(LogService);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
|
||||
private logger: LogRecorder = new LogRecorder(this.logService);
|
||||
private recoverySteps: RecoveryStep[] = [
|
||||
new UserInfoStep(this.accountService, this.keyService),
|
||||
new SyncStep(this.apiService),
|
||||
new PrivateKeyStep(
|
||||
this.privateKeyRegenerationService,
|
||||
this.dialogService,
|
||||
this.cryptoFunctionService,
|
||||
),
|
||||
new FolderStep(this.folderApiService, this.dialogService),
|
||||
new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService),
|
||||
];
|
||||
private workingData: RecoveryWorkingData | null = null;
|
||||
|
||||
readonly status = signal<StepStatus>(StepStatus.NotStarted);
|
||||
readonly hasStarted = signal(false);
|
||||
readonly diagnosticsCompleted = signal(false);
|
||||
readonly recoveryCompleted = signal(false);
|
||||
readonly steps = signal<StepState[]>(
|
||||
this.recoverySteps.map((step) => ({
|
||||
title: this.i18nService.t(step.title),
|
||||
status: StepStatus.NotStarted,
|
||||
})),
|
||||
);
|
||||
readonly hasIssues = signal(false);
|
||||
|
||||
runDiagnostics = async () => {
|
||||
if (this.status() === StepStatus.InProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasStarted.set(true);
|
||||
this.status.set(StepStatus.InProgress);
|
||||
this.diagnosticsCompleted.set(false);
|
||||
|
||||
this.logger.record("Starting diagnostics...");
|
||||
this.workingData = {
|
||||
userId: null,
|
||||
userKey: null,
|
||||
isPrivateKeyCorrupt: false,
|
||||
encryptedPrivateKey: null,
|
||||
ciphers: [],
|
||||
folders: [],
|
||||
};
|
||||
|
||||
await this.runDiagnosticsInternal();
|
||||
|
||||
this.status.set(StepStatus.Completed);
|
||||
this.diagnosticsCompleted.set(true);
|
||||
};
|
||||
|
||||
private async runDiagnosticsInternal() {
|
||||
if (!this.workingData) {
|
||||
this.logger.record("No working data available");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSteps = this.steps();
|
||||
let hasAnyFailures = false;
|
||||
|
||||
for (let i = 0; i < this.recoverySteps.length; i++) {
|
||||
const step = this.recoverySteps[i];
|
||||
currentSteps[i].status = StepStatus.InProgress;
|
||||
this.steps.set([...currentSteps]);
|
||||
|
||||
this.logger.record(`Running diagnostics for step: ${step.title}`);
|
||||
try {
|
||||
const success = await step.runDiagnostics(this.workingData, this.logger);
|
||||
currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed;
|
||||
if (!success) {
|
||||
hasAnyFailures = true;
|
||||
}
|
||||
this.steps.set([...currentSteps]);
|
||||
this.logger.record(`Diagnostics completed for step: ${step.title}`);
|
||||
} catch (error) {
|
||||
currentSteps[i].status = StepStatus.Failed;
|
||||
currentSteps[i].message = (error as Error).message;
|
||||
this.steps.set([...currentSteps]);
|
||||
this.logger.record(
|
||||
`Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`,
|
||||
);
|
||||
hasAnyFailures = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyFailures) {
|
||||
this.logger.record("Diagnostics completed with errors");
|
||||
} else {
|
||||
this.logger.record("Diagnostics completed successfully");
|
||||
}
|
||||
|
||||
// Check if any recovery can be performed
|
||||
const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!));
|
||||
this.hasIssues.set(canRecoverAnyStep);
|
||||
}
|
||||
|
||||
runRecovery = async () => {
|
||||
if (this.status() === StepStatus.InProgress || !this.workingData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set(StepStatus.InProgress);
|
||||
this.recoveryCompleted.set(false);
|
||||
|
||||
this.logger.record("Starting recovery process...");
|
||||
|
||||
try {
|
||||
for (let i = 0; i < this.recoverySteps.length; i++) {
|
||||
const step = this.recoverySteps[i];
|
||||
if (step.canRecover(this.workingData)) {
|
||||
this.logger.record(`Running recovery for step: ${step.title}`);
|
||||
await step.runRecovery(this.workingData, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.record("Recovery process completed");
|
||||
this.recoveryCompleted.set(true);
|
||||
|
||||
// Re-run diagnostics after recovery
|
||||
this.logger.record("Re-running diagnostics to verify recovery...");
|
||||
await this.runDiagnosticsInternal();
|
||||
|
||||
this.status.set(StepStatus.Completed);
|
||||
} catch (error) {
|
||||
this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`);
|
||||
this.status.set(StepStatus.Failed);
|
||||
}
|
||||
};
|
||||
|
||||
saveDiagnosticLogs = () => {
|
||||
const logs = this.logger.getLogs();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `data-recovery-logs-${timestamp}.txt`;
|
||||
|
||||
const logContent = logs.join("\n");
|
||||
this.fileDownloadService.download({
|
||||
fileName: filename,
|
||||
blobData: logContent,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
|
||||
this.logger.record("Diagnostic logs saved");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
/**
|
||||
* Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere.
|
||||
*/
|
||||
export class LogRecorder {
|
||||
private logs: string[] = [];
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
record(message: string) {
|
||||
this.logs.push(message);
|
||||
this.logService.info(`[DataRecovery] ${message}`);
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class CipherStep implements RecoveryStep {
|
||||
title = "recoveryStepCipherTitle";
|
||||
|
||||
private undecryptableCipherIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private cipherService: CipherEncryptionService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
if (!workingData.userId) {
|
||||
logger.record("Missing user ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.undecryptableCipherIds = [];
|
||||
for (const cipher of workingData.ciphers) {
|
||||
try {
|
||||
await this.cipherService.decrypt(cipher, workingData.userId);
|
||||
} catch {
|
||||
logger.record(`Cipher ID ${cipher.id} was undecryptable`);
|
||||
this.undecryptableCipherIds.push(cipher.id);
|
||||
}
|
||||
}
|
||||
logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`);
|
||||
|
||||
return this.undecryptableCipherIds.length == 0;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return this.undecryptableCipherIds.length > 0;
|
||||
}
|
||||
|
||||
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
// Recovery means deleting the broken ciphers.
|
||||
if (this.undecryptableCipherIds.length === 0) {
|
||||
logger.record("No undecryptable ciphers to recover");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`);
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "recoveryDeleteCiphersTitle" },
|
||||
content: { key: "recoveryDeleteCiphersDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
logger.record("User cancelled cipher deletion");
|
||||
throw new Error("Cipher recovery cancelled by user");
|
||||
}
|
||||
|
||||
logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`);
|
||||
|
||||
for (const cipherId of this.undecryptableCipherIds) {
|
||||
try {
|
||||
await this.apiService.deleteCipher(cipherId);
|
||||
logger.record(`Deleted cipher ${cipherId}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class FolderStep implements RecoveryStep {
|
||||
title = "recoveryStepFoldersTitle";
|
||||
|
||||
private undecryptableFolderIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private folderService: FolderApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
if (!workingData.userKey) {
|
||||
logger.record("Missing user key");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.undecryptableFolderIds = [];
|
||||
for (const folder of workingData.folders) {
|
||||
if (!folder.name?.encryptedString) {
|
||||
logger.record(`Folder ID ${folder.id} has no name`);
|
||||
this.undecryptableFolderIds.push(folder.id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await SdkLoadService.Ready;
|
||||
PureCrypto.symmetric_decrypt_string(
|
||||
folder.name.encryptedString,
|
||||
workingData.userKey.toEncoded(),
|
||||
);
|
||||
} catch {
|
||||
logger.record(`Folder name for folder ID ${folder.id} was undecryptable`);
|
||||
this.undecryptableFolderIds.push(folder.id);
|
||||
}
|
||||
}
|
||||
logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`);
|
||||
|
||||
return this.undecryptableFolderIds.length == 0;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return this.undecryptableFolderIds.length > 0;
|
||||
}
|
||||
|
||||
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
// Recovery means deleting the broken folders.
|
||||
if (this.undecryptableFolderIds.length === 0) {
|
||||
logger.record("No undecryptable folders to recover");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workingData.userId) {
|
||||
logger.record("Missing user ID");
|
||||
throw new Error("Missing user ID");
|
||||
}
|
||||
|
||||
logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`);
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "recoveryDeleteFoldersTitle" },
|
||||
content: { key: "recoveryDeleteFoldersDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
logger.record("User cancelled folder deletion");
|
||||
throw new Error("Folder recovery cancelled by user");
|
||||
}
|
||||
|
||||
logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`);
|
||||
|
||||
for (const folderId of this.undecryptableFolderIds) {
|
||||
try {
|
||||
await this.folderService.delete(folderId, workingData.userId);
|
||||
logger.record(`Deleted folder ${folderId}`);
|
||||
} catch (error) {
|
||||
logger.record(`Failed to delete folder ${folderId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`);
|
||||
}
|
||||
|
||||
getUndecryptableFolderIds(): string[] {
|
||||
return this.undecryptableFolderIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./sync-step";
|
||||
export * from "./user-info-step";
|
||||
export * from "./recovery-step";
|
||||
export * from "./private-key-step";
|
||||
export * from "./folder-step";
|
||||
export * from "./cipher-step";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class PrivateKeyStep implements RecoveryStep {
|
||||
title = "recoveryStepPrivateKeyTitle";
|
||||
|
||||
constructor(
|
||||
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private dialogService: DialogService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
if (!workingData.userId || !workingData.userKey) {
|
||||
logger.record("Missing user ID or user key");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation.
|
||||
const encryptedPrivateKey = workingData.encryptedPrivateKey;
|
||||
if (!encryptedPrivateKey) {
|
||||
logger.record("No encrypted private key found");
|
||||
return false;
|
||||
}
|
||||
logger.record("Private key length: " + encryptedPrivateKey.length);
|
||||
let privateKey: Uint8Array;
|
||||
try {
|
||||
await SdkLoadService.Ready;
|
||||
privateKey = PureCrypto.unwrap_decapsulation_key(
|
||||
encryptedPrivateKey,
|
||||
workingData.userKey.toEncoded(),
|
||||
);
|
||||
} catch {
|
||||
logger.record("Private key was un-decryptable");
|
||||
workingData.isPrivateKeyCorrupt = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding.
|
||||
try {
|
||||
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
logger.record("Public key length: " + publicKey.length);
|
||||
} catch {
|
||||
logger.record("Public key could not be derived; private key is corrupt");
|
||||
workingData.isPrivateKeyCorrupt = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
// Only support recovery on V1 users.
|
||||
return (
|
||||
workingData.isPrivateKeyCorrupt &&
|
||||
workingData.userKey !== null &&
|
||||
workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64
|
||||
);
|
||||
}
|
||||
|
||||
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
// The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization.
|
||||
// This is because this will break emergency access enrollments / organization memberships / provider memberships.
|
||||
logger.record("Showing confirmation dialog for private key replacement");
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "recoveryReplacePrivateKeyTitle" },
|
||||
content: { key: "recoveryReplacePrivateKeyDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
logger.record("User cancelled private key replacement");
|
||||
throw new Error("Private key recovery cancelled by user");
|
||||
}
|
||||
|
||||
logger.record("Replacing private key");
|
||||
await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair(
|
||||
workingData.userId!,
|
||||
);
|
||||
logger.record("Private key replaced successfully");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WrappedPrivateKey } from "@bitwarden/common/key-management/types";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
/**
|
||||
* A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers.
|
||||
*/
|
||||
export abstract class RecoveryStep {
|
||||
/** Title of the recovery step, as an i18n key. */
|
||||
abstract title: string;
|
||||
|
||||
/**
|
||||
* Runs diagnostics on the provided working data.
|
||||
* Returns true if no issues were found, false otherwise.
|
||||
*/
|
||||
abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Returns whether recovery can be performed
|
||||
*/
|
||||
abstract canRecover(workingData: RecoveryWorkingData): boolean;
|
||||
|
||||
/**
|
||||
* Performs recovery on the provided working data.
|
||||
*/
|
||||
abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data used during the recovery process, passed between steps.
|
||||
*/
|
||||
export type RecoveryWorkingData = {
|
||||
userId: UserId | null;
|
||||
userKey: UserKey | null;
|
||||
encryptedPrivateKey: WrappedPrivateKey | null;
|
||||
isPrivateKeyCorrupt: boolean;
|
||||
ciphers: Cipher[];
|
||||
folders: Folder[];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { FolderData } from "@bitwarden/common/vault/models/data/folder.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class SyncStep implements RecoveryStep {
|
||||
title = "recoveryStepSyncTitle";
|
||||
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
// The intent of this step is to fetch the latest data from the server. Diagnostics does not
|
||||
// ever run on local data but only remote data that is recent.
|
||||
const response = await this.apiService.getSync();
|
||||
|
||||
workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c)));
|
||||
logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`);
|
||||
|
||||
workingData.folders = response.folders.map((f) => new Folder(new FolderData(f)));
|
||||
logger.record(`Fetched ${workingData.folders.length} folders from server`);
|
||||
|
||||
workingData.encryptedPrivateKey =
|
||||
response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null;
|
||||
logger.record(
|
||||
`Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class UserInfoStep implements RecoveryStep {
|
||||
title = "recoveryStepUserInfoTitle";
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount) {
|
||||
logger.record("No active account found");
|
||||
return false;
|
||||
}
|
||||
const userId = activeAccount.id;
|
||||
workingData.userId = userId;
|
||||
logger.record(`User ID: ${userId}`);
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (!userKey) {
|
||||
logger.record("No user key found");
|
||||
return false;
|
||||
}
|
||||
workingData.userKey = userKey;
|
||||
logger.record(
|
||||
`User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<div *ngIf="loading" class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading">
|
||||
<p>{{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}</p>
|
||||
<p class="tw-mb-0">{{ "organizationName" | i18n }}:</p>
|
||||
<p class="tw-text-muted tw-mb-6">{{ organization.name }}</p>
|
||||
<p class="tw-mb-0">{{ "keyConnectorDomain" | i18n }}:</p>
|
||||
<p class="tw-text-muted tw-mb-6">{{ organization.keyConnectorUrl }}</p>
|
||||
|
||||
<button
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="primary"
|
||||
class="tw-w-full tw-mb-2"
|
||||
[bitAction]="convert"
|
||||
[block]="true"
|
||||
>
|
||||
{{ "removeMasterPassword" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
class="tw-w-full"
|
||||
[bitAction]="leave"
|
||||
[block]="true"
|
||||
>
|
||||
{{ "leaveOrganization" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
// 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-remove-password",
|
||||
templateUrl: "remove-password.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class RemovePasswordComponent extends BaseRemovePasswordComponent {}
|
||||
@@ -30,6 +30,7 @@ import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -286,9 +287,10 @@ describe("KeyRotationService", () => {
|
||||
|
||||
const mockUser = {
|
||||
id: "mockUserId" as UserId,
|
||||
email: "mockEmail",
|
||||
emailVerified: true,
|
||||
name: "mockName",
|
||||
...mockAccountInfoWith({
|
||||
email: "mockEmail",
|
||||
name: "mockName",
|
||||
}),
|
||||
};
|
||||
|
||||
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
|
||||
@@ -1096,6 +1098,9 @@ describe("KeyRotationService", () => {
|
||||
mockKeyService.userSigningKey$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey),
|
||||
);
|
||||
mockKeyService.userSignedPublicKey$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 as SignedPublicKey),
|
||||
);
|
||||
mockSecurityStateService.accountSecurityState$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState),
|
||||
);
|
||||
@@ -1140,6 +1145,7 @@ describe("KeyRotationService", () => {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 as SignedPublicKey,
|
||||
},
|
||||
signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import {
|
||||
SignedPublicKey,
|
||||
SignedSecurityState,
|
||||
UnsignedPublicKey,
|
||||
WrappedPrivateKey,
|
||||
@@ -308,9 +309,11 @@ export class UserKeyRotationService {
|
||||
userId: asUuid(userId),
|
||||
kdfParams: kdfConfig.toSdkConfig(),
|
||||
email: email,
|
||||
privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signingKey: undefined,
|
||||
securityState: undefined,
|
||||
accountCryptographicState: {
|
||||
V1: {
|
||||
private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
},
|
||||
},
|
||||
method: {
|
||||
decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
|
||||
},
|
||||
@@ -334,9 +337,15 @@ export class UserKeyRotationService {
|
||||
userId: asUuid(userId),
|
||||
kdfParams: kdfConfig.toSdkConfig(),
|
||||
email: email,
|
||||
privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signingKey: cryptographicStateParameters.signingKey,
|
||||
securityState: cryptographicStateParameters.securityState,
|
||||
accountCryptographicState: {
|
||||
V2: {
|
||||
private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signing_key: cryptographicStateParameters.signingKey,
|
||||
security_state: cryptographicStateParameters.securityState,
|
||||
signed_public_key:
|
||||
cryptographicStateParameters.publicKeyEncryptionKeyPair.signedPublicKey,
|
||||
},
|
||||
},
|
||||
method: {
|
||||
decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
|
||||
},
|
||||
@@ -632,6 +641,10 @@ export class UserKeyRotationService {
|
||||
this.securityStateService.accountSecurityState$(user.id),
|
||||
"User security state",
|
||||
);
|
||||
const signedPublicKey = await this.firstValueFromOrThrow(
|
||||
this.keyService.userSignedPublicKey$(user.id),
|
||||
"User signed public key",
|
||||
);
|
||||
|
||||
return {
|
||||
masterKeyKdfConfig,
|
||||
@@ -642,6 +655,7 @@ export class UserKeyRotationService {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: currentUserKeyWrappedPrivateKey,
|
||||
publicKey: publicKey,
|
||||
signedPublicKey: signedPublicKey!,
|
||||
},
|
||||
signingKey: signingKey!,
|
||||
securityState: securityState!,
|
||||
@@ -679,6 +693,7 @@ export type V2CryptographicStateParameters = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: WrappedPrivateKey;
|
||||
publicKey: UnsignedPublicKey;
|
||||
signedPublicKey: SignedPublicKey;
|
||||
};
|
||||
signingKey: WrappedSigningKey;
|
||||
securityState: SignedSecurityState;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { defer, Observable, of } from "rxjs";
|
||||
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
|
||||
|
||||
export class WebSessionTimeoutSettingsComponentService
|
||||
implements SessionTimeoutSettingsComponentService
|
||||
{
|
||||
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = defer(() => {
|
||||
const options: VaultTimeoutOption[] = [
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
|
||||
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
|
||||
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
|
||||
{ name: this.i18nService.t("oneHour"), value: 60 },
|
||||
{ name: this.i18nService.t("fourHours"), value: 240 },
|
||||
{ name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart },
|
||||
];
|
||||
|
||||
if (this.platformUtilsService.isDev()) {
|
||||
options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never });
|
||||
}
|
||||
|
||||
return of(options);
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
onTimeoutSave(_: VaultTimeout): void {}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
VaultTimeoutNumberType,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { WebSessionTimeoutTypeService } from "./web-session-timeout-type.service";
|
||||
|
||||
describe("WebSessionTimeoutTypeService", () => {
|
||||
let service: WebSessionTimeoutTypeService;
|
||||
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
service = new WebSessionTimeoutTypeService(mockPlatformUtilsService);
|
||||
});
|
||||
|
||||
describe("isAvailable", () => {
|
||||
it("should return false for Immediately", async () => {
|
||||
const result = await service.isAvailable(VaultTimeoutNumberType.Immediately);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it.each([VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.Custom])(
|
||||
"should return true for always available type: %s",
|
||||
async (timeoutType) => {
|
||||
const result = await service.isAvailable(timeoutType);
|
||||
|
||||
expect(result).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])(
|
||||
"should return true for numeric timeout type: %s",
|
||||
async (timeoutType) => {
|
||||
const result = await service.isAvailable(timeoutType);
|
||||
|
||||
expect(result).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
])("should return false for unavailable timeout type: %s", async (timeoutType) => {
|
||||
const result = await service.isAvailable(timeoutType);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
describe("Never availability", () => {
|
||||
it("should return true when in dev mode", async () => {
|
||||
mockPlatformUtilsService.isDev.mockReturnValue(true);
|
||||
|
||||
const result = await service.isAvailable(VaultTimeoutStringType.Never);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPlatformUtilsService.isDev).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false when not in dev mode", async () => {
|
||||
mockPlatformUtilsService.isDev.mockReturnValue(false);
|
||||
|
||||
const result = await service.isAvailable(VaultTimeoutStringType.Never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockPlatformUtilsService.isDev).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrPromoteToAvailable", () => {
|
||||
it.each([
|
||||
VaultTimeoutNumberType.OnMinute,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.Custom,
|
||||
])("should return the original type when it is available: %s", async (timeoutType) => {
|
||||
jest.spyOn(service, "isAvailable").mockResolvedValue(true);
|
||||
|
||||
const result = await service.getOrPromoteToAvailable(timeoutType);
|
||||
|
||||
expect(result).toBe(timeoutType);
|
||||
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
|
||||
});
|
||||
|
||||
it("should return OnMinute when Immediately is not available", async () => {
|
||||
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
|
||||
|
||||
const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately);
|
||||
|
||||
expect(result).toBe(VaultTimeoutNumberType.OnMinute);
|
||||
expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately);
|
||||
});
|
||||
|
||||
it.each([
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.Never,
|
||||
])("should return OnRestart when type is not available: %s", async (timeoutType) => {
|
||||
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
|
||||
|
||||
const result = await service.getOrPromoteToAvailable(timeoutType);
|
||||
|
||||
expect(result).toBe(VaultTimeoutStringType.OnRestart);
|
||||
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
|
||||
import {
|
||||
isVaultTimeoutTypeNumeric,
|
||||
VaultTimeout,
|
||||
VaultTimeoutNumberType,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
export class WebSessionTimeoutTypeService implements SessionTimeoutTypeService {
|
||||
constructor(private readonly platformUtilsService: PlatformUtilsService) {}
|
||||
|
||||
async isAvailable(type: VaultTimeout): Promise<boolean> {
|
||||
switch (type) {
|
||||
case VaultTimeoutNumberType.Immediately:
|
||||
return false;
|
||||
case VaultTimeoutStringType.OnRestart:
|
||||
case VaultTimeoutStringType.Custom:
|
||||
return true;
|
||||
case VaultTimeoutStringType.Never:
|
||||
return this.platformUtilsService.isDev();
|
||||
default:
|
||||
if (isVaultTimeoutTypeNumeric(type)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async getOrPromoteToAvailable(type: VaultTimeout): Promise<VaultTimeout> {
|
||||
const available = await this.isAvailable(type);
|
||||
if (!available) {
|
||||
switch (type) {
|
||||
case VaultTimeoutNumberType.Immediately:
|
||||
return VaultTimeoutNumberType.OnMinute;
|
||||
default:
|
||||
return VaultTimeoutStringType.OnRestart;
|
||||
}
|
||||
}
|
||||
return type;
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,14 @@ class MockSyncService implements Partial<SyncService> {
|
||||
}
|
||||
|
||||
class MockAccountService implements Partial<AccountService> {
|
||||
// We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec.
|
||||
// This is because that package relies on jest dependencies that aren't available here.
|
||||
activeAccount$?: Observable<Account> = of({
|
||||
id: "test-user-id" as UserId,
|
||||
name: "Test User 1",
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
creationDate: "2024-01-01T00:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,14 @@ class MockSyncService implements Partial<SyncService> {
|
||||
}
|
||||
|
||||
class MockAccountService implements Partial<AccountService> {
|
||||
// We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec.
|
||||
// This is because that package relies on jest dependencies that aren't available here.
|
||||
activeAccount$?: Observable<Account> = of({
|
||||
id: "test-user-id" as UserId,
|
||||
name: "Test User 1",
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
creationDate: "2024-01-01T00:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item>
|
||||
<bit-nav-group icon="bwi-wrench" [text]="'tools' | i18n" route="tools">
|
||||
<bit-nav-item [text]="'generator' | i18n" route="tools/generator"></bit-nav-item>
|
||||
<bit-nav-item [text]="'importData' | i18n" route="tools/import"></bit-nav-item>
|
||||
<bit-nav-item [text]="'exportVault' | i18n" route="tools/export"></bit-nav-item>
|
||||
<bit-nav-item [text]="'import' | i18n" route="tools/import"></bit-nav-item>
|
||||
<bit-nav-item [text]="'export' | i18n" route="tools/export"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item icon="bwi-sliders" [text]="'reports' | i18n" route="reports"></bit-nav-item>
|
||||
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent } from "@bitwarden/key-management-ui";
|
||||
import { LockComponent, RemovePasswordComponent } from "@bitwarden/key-management-ui";
|
||||
import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard";
|
||||
|
||||
import { flagEnabled, Flags } from "../utils/flags";
|
||||
@@ -78,8 +78,8 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
|
||||
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
|
||||
import { RouteDataProperties } from "./core";
|
||||
import { ReportsModule } from "./dirt/reports";
|
||||
import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component";
|
||||
import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component";
|
||||
import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
|
||||
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "./layouts/user-layout.component";
|
||||
import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component";
|
||||
@@ -544,9 +544,9 @@ const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "removeMasterPassword",
|
||||
key: "verifyYourOrganization",
|
||||
},
|
||||
titleId: "removeMasterPassword",
|
||||
titleId: "verifyYourOrganization",
|
||||
pageIcon: LockIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
@@ -556,9 +556,9 @@ const routes: Routes = [
|
||||
canActivate: [],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "confirmKeyConnectorDomain",
|
||||
key: "verifyYourOrganization",
|
||||
},
|
||||
titleId: "confirmKeyConnectorDomain",
|
||||
titleId: "verifyYourOrganization",
|
||||
pageIcon: DomainIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
@@ -696,6 +696,12 @@ const routes: Routes = [
|
||||
path: "security",
|
||||
loadChildren: () => SecurityRoutingModule,
|
||||
},
|
||||
{
|
||||
path: "data-recovery",
|
||||
component: DataRecoveryComponent,
|
||||
canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)],
|
||||
data: { titleId: "dataRecovery" } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "domain-rules",
|
||||
component: DomainRulesComponent,
|
||||
@@ -742,7 +748,7 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent),
|
||||
data: {
|
||||
titleId: "importData",
|
||||
titleId: "import",
|
||||
} satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
@@ -752,7 +758,7 @@ const routes: Routes = [
|
||||
(mod) => mod.ExportWebComponent,
|
||||
),
|
||||
data: {
|
||||
titleId: "exportVault",
|
||||
titleId: "export",
|
||||
} satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
|
||||
</span>
|
||||
</bit-callout>
|
||||
<bit-session-timeout-input
|
||||
<bit-session-timeout-input-legacy
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</bit-session-timeout-input>
|
||||
</bit-session-timeout-input-legacy>
|
||||
<ng-container *ngIf="availableVaultTimeoutActions$ | async as availableVaultTimeoutActions">
|
||||
<bit-radio-group
|
||||
formControlName="vaultTimeoutAction"
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { SessionTimeoutInputComponent } from "@bitwarden/key-management-ui";
|
||||
import { SessionTimeoutInputLegacyComponent } from "@bitwarden/key-management-ui";
|
||||
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
|
||||
import { HeaderModule } from "../layouts/header/header.module";
|
||||
@@ -52,8 +52,8 @@ import { SharedModule } from "../shared";
|
||||
imports: [
|
||||
SharedModule,
|
||||
HeaderModule,
|
||||
SessionTimeoutInputComponent,
|
||||
PermitCipherDetailsPopoverComponent,
|
||||
SessionTimeoutInputLegacyComponent,
|
||||
],
|
||||
})
|
||||
export class PreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
|
||||
import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component";
|
||||
import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component";
|
||||
import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component";
|
||||
import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component";
|
||||
import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component";
|
||||
import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import { HeaderModule } from "../layouts/header/header.module";
|
||||
import { OrganizationBadgeModule } from "../vault/individual-vault/organization-badge/organization-badge.module";
|
||||
import { PipesModule } from "../vault/individual-vault/pipes/pipes.module";
|
||||
@@ -19,22 +14,10 @@ import { SharedModule } from "./shared.module";
|
||||
@NgModule({
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
declarations: [
|
||||
RecoverDeleteComponent,
|
||||
RecoverTwoFactorComponent,
|
||||
RemovePasswordComponent,
|
||||
SponsoredFamiliesComponent,
|
||||
FreeBitwardenFamiliesComponent,
|
||||
SponsoringOrgRowComponent,
|
||||
VerifyEmailTokenComponent,
|
||||
VerifyRecoverDeleteComponent,
|
||||
],
|
||||
exports: [
|
||||
RecoverDeleteComponent,
|
||||
RecoverTwoFactorComponent,
|
||||
RemovePasswordComponent,
|
||||
SponsoredFamiliesComponent,
|
||||
VerifyEmailTokenComponent,
|
||||
VerifyRecoverDeleteComponent,
|
||||
],
|
||||
exports: [SponsoredFamiliesComponent],
|
||||
})
|
||||
export class LooseComponentsModule {}
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
>
|
||||
{{ "importData" | i18n }}
|
||||
{{ "import" | i18n }}
|
||||
</button>
|
||||
</bit-container>
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
>
|
||||
{{ "importData" | i18n }}
|
||||
{{ "import" | i18n }}
|
||||
</button>
|
||||
</bit-container>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SendAddEditDialogComponent } from "@bitwarden/send-ui";
|
||||
|
||||
import { NewSendDropdownComponent } from "./new-send-dropdown.component";
|
||||
|
||||
describe("NewSendDropdownComponent", () => {
|
||||
let component: NewSendDropdownComponent;
|
||||
let fixture: ComponentFixture<NewSendDropdownComponent>;
|
||||
const mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
const mockAccountService = mock<AccountService>();
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockPolicyService = mock<PolicyService>();
|
||||
const mockSendService = mock<SendService>();
|
||||
const mockPremiumUpgradePromptService = mock<PremiumUpgradePromptService>();
|
||||
const mockSendApiService = mock<SendApiService>();
|
||||
|
||||
beforeAll(() => {
|
||||
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockImplementation(() =>
|
||||
of(true),
|
||||
);
|
||||
mockAccountService.activeAccount$ = of({ id: "myTestAccount" } as Account);
|
||||
mockPolicyService.policyAppliesToUser$.mockImplementation(() => of(false));
|
||||
mockPremiumUpgradePromptService.promptForPremium.mockImplementation(async () => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NewSendDropdownComponent],
|
||||
declarations: [],
|
||||
providers: [
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: SendService, useValue: mockSendService },
|
||||
{ provide: PremiumUpgradePromptService, useValue: mockPremiumUpgradePromptService },
|
||||
{ provide: SendApiService, useValue: mockSendApiService },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(NewSendDropdownComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should open send dialog in a popup without feature flag", async () => {
|
||||
const openSpy = jest.spyOn(SendAddEditDialogComponent, "open");
|
||||
const openDrawerSpy = jest.spyOn(SendAddEditDialogComponent, "openDrawer");
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await component.createSend(SendType.Text);
|
||||
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
expect(openDrawerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should open send dialog in drawer with feature flag", async () => {
|
||||
const openSpy = jest.spyOn(SendAddEditDialogComponent, "open");
|
||||
const openDrawerSpy = jest.spyOn(SendAddEditDialogComponent, "openDrawer");
|
||||
mockConfigService.getFeatureFlag.mockImplementation(async (key) =>
|
||||
key === FeatureFlag.SendUIRefresh ? true : false,
|
||||
);
|
||||
|
||||
await component.createSend(SendType.Text);
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
expect(openDrawerSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components";
|
||||
import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui";
|
||||
@@ -38,6 +40,7 @@ export class NewSendDropdownComponent {
|
||||
private accountService: AccountService,
|
||||
private dialogService: DialogService,
|
||||
private addEditFormConfigService: DefaultSendFormConfigService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
@@ -60,6 +63,11 @@ export class NewSendDropdownComponent {
|
||||
|
||||
const formConfig = await this.addEditFormConfigService.buildConfig("add", undefined, type);
|
||||
|
||||
SendAddEditDialogComponent.open(this.dialogService, { formConfig });
|
||||
const useRefresh = await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh);
|
||||
if (useRefresh) {
|
||||
SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig });
|
||||
} else {
|
||||
SendAddEditDialogComponent.open(this.dialogService, { formConfig });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<p>{{ send.file.fileName }}</p>
|
||||
<p class="tw-text-wrap tw-break-all">{{ send.file.fileName }}</p>
|
||||
<button bitButton type="button" buttonType="primary" [bitAction]="download" [block]="true">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }})
|
||||
|
||||
@@ -80,13 +80,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-col-span-9">
|
||||
<div class="tw-col-span-9 tw-@container/send-table">
|
||||
<!--Listing Table-->
|
||||
<bit-table [dataSource]="dataSource" *ngIf="filteredSends && filteredSends.length">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
|
||||
<th bitCell bitSortable="deletionDate">{{ "deletionDate" | i18n }}</th>
|
||||
<th bitCell bitSortable="deletionDate" class="@lg/send-table:tw-table-cell tw-hidden">
|
||||
{{ "deletionDate" | i18n }}
|
||||
</th>
|
||||
<th bitCell>{{ "options" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
@@ -148,8 +150,14 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell class="tw-text-muted" (click)="editSend(s)" class="tw-cursor-pointer">
|
||||
<small bitTypography="body2" appStopProp>{{ s.deletionDate | date: "medium" }}</small>
|
||||
<td
|
||||
bitCell
|
||||
(click)="editSend(s)"
|
||||
class="tw-text-muted tw-cursor-pointer @lg/send-table:tw-table-cell tw-hidden"
|
||||
>
|
||||
<small bitTypography="body2" appStopProp>
|
||||
{{ s.deletionDate | date: "medium" }}
|
||||
</small>
|
||||
</td>
|
||||
<td bitCell class="tw-w-0 tw-text-right">
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,9 @@ import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/sen
|
||||
import { NoSendsIcon } from "@bitwarden/assets/svg";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -77,6 +79,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
|
||||
toastService: ToastService,
|
||||
private addEditFormConfigService: DefaultSendFormConfigService,
|
||||
accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(
|
||||
sendService,
|
||||
@@ -144,14 +147,21 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
|
||||
* @param formConfig The form configuration.
|
||||
* */
|
||||
async openSendItemDialog(formConfig: SendFormConfig) {
|
||||
// Prevent multiple dialogs from being opened.
|
||||
if (this.sendItemDialogRef) {
|
||||
const useRefresh = await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh);
|
||||
// Prevent multiple dialogs from being opened but allow drawers since they will prevent multiple being open themselves
|
||||
if (this.sendItemDialogRef && !useRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendItemDialogRef = SendAddEditDialogComponent.open(this.dialogService, {
|
||||
formConfig,
|
||||
});
|
||||
if (useRefresh) {
|
||||
this.sendItemDialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
|
||||
formConfig,
|
||||
});
|
||||
} else {
|
||||
this.sendItemDialogRef = SendAddEditDialogComponent.open(this.dialogService, {
|
||||
formConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await lastValueFrom(this.sendItemDialogRef.closed);
|
||||
this.sendItemDialogRef = undefined;
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
>
|
||||
{{ "confirmFormat" | i18n }}
|
||||
{{ "export" | i18n }}
|
||||
</button>
|
||||
</bit-container>
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
>
|
||||
{{ "confirmFormat" | i18n }}
|
||||
{{ "export" | i18n }}
|
||||
</button>
|
||||
</bit-container>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user