1
0
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:
Will Martin
2025-12-16 15:36:46 -05:00
committed by GitHub
1154 changed files with 82179 additions and 22897 deletions

View File

@@ -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>

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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,
},
});
});
});
});

View File

@@ -1,31 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
firstValueFrom,
Observable,
of,
switchMap,
first,
map,
withLatestFrom,
tap,
} from "rxjs";
import { combineLatest, Observable, of, switchMap, first, map } 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,
},
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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) || [],

View File

@@ -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>

View File

@@ -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) =>

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
@@ -46,11 +47,12 @@ describe("UpgradePaymentService", () => {
let sut: UpgradePaymentService;
const mockAccount = {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const mockTokenizedPaymentMethod: TokenizedPaymentMethod = {
@@ -151,9 +153,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {
@@ -203,9 +206,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {
@@ -255,9 +259,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
mockAccountService.activeAccount$ = of(mockAccount);
@@ -289,9 +294,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const expectedCredit = 25.5;
@@ -353,9 +359,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -14,16 +14,18 @@
></app-subscription-status>
<ng-container *ngIf="userOrg.canEditSubscription">
<div class="tw-flex-col">
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>{{ "details" | i18n
}}<span
class="tw-ml-3"
<strong
class="tw-flex tw-items-center tw-gap-3 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-pb-2"
>
{{ "details" | i18n }}
<span
*ngIf="customerDiscount?.percentOff > 0 && !isSecretsManagerTrial()"
bitBadge
variant="success"
class="tw-inline-flex tw-items-center"
>{{ "providerDiscount" | i18n: customerDiscount?.percentOff }}</span
></strong
>
>
</strong>
<bit-table>
<ng-template body>
<ng-container *ngIf="subscription && !userOrg.isFreeOrg">
@@ -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: "$"

View File

@@ -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() {

View File

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

View File

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

View File

@@ -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,
})

View File

@@ -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");

View File

@@ -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";

View File

@@ -219,4 +219,8 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
getAutofillKeyboardShortcut(): Promise<string> {
return null;
}
packageType(): Promise<string | null> {
return null;
}
}

View File

@@ -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 () => {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>

View File

@@ -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/);
});
});
});

View File

@@ -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");
};
}

View File

@@ -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];
}
}

View File

@@ -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`);
}
}

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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");
}
}

View File

@@ -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[];
};

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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",
});
}

View File

@@ -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",
});
}

View File

@@ -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">

View File

@@ -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,
},
{

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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 {}

View File

@@ -15,6 +15,6 @@
bitFormButton
buttonType="primary"
>
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
</bit-container>

View File

@@ -16,6 +16,6 @@
bitFormButton
buttonType="primary"
>
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
</bit-container>

View File

@@ -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();
});
});

View File

@@ -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 });
}
}
}

View File

@@ -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 }})

View File

@@ -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

View File

@@ -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;

View File

@@ -15,6 +15,6 @@
bitFormButton
buttonType="primary"
>
{{ "confirmFormat" | i18n }}
{{ "export" | i18n }}
</button>
</bit-container>

View File

@@ -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