1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 12:40:26 +00:00

Merge branch 'main' into km/encstring-remove-decrypt

This commit is contained in:
Bernd Schoolmann
2025-10-28 16:47:29 +01:00
committed by GitHub
36 changed files with 2142 additions and 229 deletions

View File

@@ -26,7 +26,6 @@ const eventsToTest = [
EVENTS.CHANGE,
EVENTS.INPUT,
EVENTS.KEYDOWN,
EVENTS.KEYPRESS,
EVENTS.KEYUP,
"blur",
"click",
@@ -1044,13 +1043,13 @@ describe("InsertAutofillContentService", () => {
});
describe("simulateUserKeyboardEventInteractions", () => {
it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => {
it("will trigger `keydown` and `keyup` events on the passed element", () => {
const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement;
jest.spyOn(inputElement, "dispatchEvent");
insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement);
[EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => {
[EVENTS.KEYDOWN, EVENTS.KEYUP].forEach((eventName) => {
expect(inputElement.dispatchEvent).toHaveBeenCalledWith(
new KeyboardEvent(eventName, { bubbles: true }),
);

View File

@@ -136,7 +136,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
setTimeout(() => {
this.autofillInsertActions[action]({ opid, value });
resolve();
}, delayActionsInMilliseconds * actionIndex),
}, delayActionsInMilliseconds),
);
};
@@ -349,7 +349,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private simulateUserKeyboardEventInteractions(element: FormFieldElement): void {
const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP];
const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYUP];
for (let index = 0; index < simulatedKeyboardEvents.length; index++) {
element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true }));
}

View File

@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import {
DefaultOrganizationUserService,
OrganizationUserApiService,
OrganizationUserBulkConfirmRequest,
OrganizationUserBulkPublicKeyResponse,
@@ -26,8 +27,6 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
import { BulkUserDetails } from "./bulk-status.component";
@@ -54,7 +53,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
private organizationUserApiService: OrganizationUserApiService,
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private organizationUserService: DefaultOrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);

View File

@@ -2,4 +2,3 @@ export { OrganizationMembersService } from "./organization-members-service/organ
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";
export { OrganizationUserService } from "./organization-user/organization-user.service";

View File

@@ -10,6 +10,7 @@ import {
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
@@ -20,7 +21,6 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
@@ -34,7 +34,7 @@ describe("MemberActionsService", () => {
let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let billingConstraintService: MockProxy<BillingConstraintService>;
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId;
@@ -50,7 +50,7 @@ describe("MemberActionsService", () => {
encryptService = mock<EncryptService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
billingConstraintService = mock<BillingConstraintService>();
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganization = {
id: organizationId,
@@ -75,7 +75,7 @@ describe("MemberActionsService", () => {
encryptService,
configService,
accountService,
billingConstraintService,
organizationMetadataService,
);
});
@@ -251,7 +251,7 @@ describe("MemberActionsService", () => {
expect(result).toEqual({ success: true });
expect(organizationUserService.confirmUser).toHaveBeenCalledWith(
mockOrganization,
mockOrgUser,
mockOrgUser.id,
publicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();

View File

@@ -2,6 +2,7 @@ import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import {
DefaultOrganizationUserService,
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserConfirmRequest,
@@ -21,7 +22,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
export interface MemberActionResult {
success: boolean;
@@ -39,7 +39,7 @@ export class MemberActionsService {
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private organizationUserService: DefaultOrganizationUserService,
private keyService: KeyService,
private encryptService: EncryptService,
private configService: ConfigService,
@@ -129,7 +129,7 @@ export class MemberActionsService {
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user, publicKey),
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
} else {
const request = await firstValueFrom(

View File

@@ -1,6 +1,5 @@
import { mock, mockReset } from "jest-mock-extended";
import * as rxjs from "rxjs";
import { of } from "rxjs";
import { of, BehaviorSubject } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -8,6 +7,7 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { DialogRef, DialogService } from "@bitwarden/components";
@@ -28,6 +28,7 @@ describe("UnifiedUpgradePromptService", () => {
const mockDialogService = mock<DialogService>();
const mockOrganizationService = mock<OrganizationService>();
const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
const mockPlatformUtilsService = mock<PlatformUtilsService>();
/**
* Creates a mock DialogRef that implements the required properties for testing
@@ -57,33 +58,33 @@ describe("UnifiedUpgradePromptService", () => {
mockSyncService,
mockDialogService,
mockOrganizationService,
mockPlatformUtilsService,
);
}
const mockAccount: Account = {
id: "test-user-id",
} as Account;
const accountSubject = new rxjs.BehaviorSubject(mockAccount);
const accountSubject = new BehaviorSubject<Account | null>(mockAccount);
describe("initialization", () => {
beforeEach(() => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupTestService();
});
it("should be created", () => {
expect(sut).toBeTruthy();
});
it("should subscribe to account and feature flag observables on construction", () => {
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog,
);
});
});
describe("displayUpgradePromptConditionally", () => {
beforeEach(async () => {
beforeEach(() => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockDialogOpen.mockReset();
mockReset(mockDialogService);
mockReset(mockConfigService);
mockReset(mockBillingService);
mockReset(mockVaultProfileService);
@@ -93,20 +94,48 @@ describe("UnifiedUpgradePromptService", () => {
// Mock sync service methods
mockSyncService.fullSync.mockResolvedValue(true);
mockSyncService.lastSync$.mockReturnValue(of(new Date()));
mockReset(mockPlatformUtilsService);
});
it("should subscribe to account and feature flag observables when checking display conditions", async () => {
// Arrange
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog,
);
expect(mockAccountService.activeAccount$).toBeDefined();
});
it("should not show dialog when feature flag is disabled", async () => {
// Arrange
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has premium", async () => {
// Arrange
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
@@ -117,6 +146,7 @@ describe("UnifiedUpgradePromptService", () => {
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has any organization membership", async () => {
@@ -124,6 +154,7 @@ describe("UnifiedUpgradePromptService", () => {
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any]));
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
setupTestService();
// Act
@@ -131,6 +162,7 @@ describe("UnifiedUpgradePromptService", () => {
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when profile is older than 5 minutes", async () => {
@@ -141,6 +173,7 @@ describe("UnifiedUpgradePromptService", () => {
const oldDate = new Date();
oldDate.setMinutes(oldDate.getMinutes() - 10); // 10 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(oldDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
setupTestService();
// Act
@@ -148,6 +181,7 @@ describe("UnifiedUpgradePromptService", () => {
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should show dialog when all conditions are met", async () => {
@@ -158,6 +192,7 @@ describe("UnifiedUpgradePromptService", () => {
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
@@ -182,6 +217,7 @@ describe("UnifiedUpgradePromptService", () => {
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when profile creation date is unavailable", async () => {
@@ -190,6 +226,8 @@ describe("UnifiedUpgradePromptService", () => {
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
setupTestService();
// Act
@@ -197,6 +235,26 @@ describe("UnifiedUpgradePromptService", () => {
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when running in self-hosted environment", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(true);
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@angular/core";
import { combineLatest, firstValueFrom, timeout } from "rxjs";
import { filter, switchMap, take } from "rxjs/operators";
import { combineLatest, firstValueFrom, timeout, from, Observable, of } from "rxjs";
import { filter, switchMap, take, map } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -8,7 +8,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
@@ -29,63 +31,37 @@ export class UnifiedUpgradePromptService {
private syncService: SyncService,
private dialogService: DialogService,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
) {}
private shouldShowPrompt$ = combineLatest([
this.accountService.activeAccount$,
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
]).pipe(
switchMap(async ([account, isFlagEnabled]) => {
if (!account || !account?.id) {
return false;
}
// Early return if feature flag is disabled
if (!isFlagEnabled) {
return false;
private shouldShowPrompt$: Observable<boolean> = this.accountService.activeAccount$.pipe(
switchMap((account) => {
// Check self-hosted first before any other operations
if (this.platformUtilsService.isSelfHost()) {
return of(false);
}
// Wait for sync to complete to ensure organizations are fully loaded
// Also force a sync to ensure we have the latest data
await this.syncService.fullSync(false);
if (!account) {
return of(false);
}
// Wait for the sync to complete with timeout to prevent hanging
await firstValueFrom(
this.syncService.lastSync$(account.id).pipe(
filter((lastSync) => lastSync !== null),
take(1),
timeout(30000), // 30 second timeout
),
const isProfileLessThanFiveMinutesOld = from(
this.isProfileLessThanFiveMinutesOld(account.id),
);
const hasOrganizations = from(this.hasOrganizations(account.id));
// Check if user has premium
const hasPremium = await firstValueFrom(
return combineLatest([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
]).pipe(
map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, isFlagEnabled]) => {
return (
isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && isFlagEnabled
);
}),
);
// Early return if user already has premium
if (hasPremium) {
return false;
}
// Check if user has any organization membership (any status including pending)
// Try using memberOrganizations$ which might have different filtering logic
const memberOrganizations = await firstValueFrom(
this.organizationService.memberOrganizations$(account.id),
);
const hasOrganizations = memberOrganizations.length > 0;
// Early return if user has any organization status
if (hasOrganizations) {
return false;
}
// Check profile age only if needed
const isProfileLessThanFiveMinutesOld = await this.isProfileLessThanFiveMinutesOld(
account.id,
);
return isFlagEnabled && !hasPremium && !hasOrganizations && isProfileLessThanFiveMinutesOld;
}),
take(1),
);
@@ -119,7 +95,7 @@ export class UnifiedUpgradePromptService {
const nowInMs = new Date().getTime();
const differenceInMs = nowInMs - createdAtInMs;
const msInAMinute = 1000 * 60; // Milliseconds in a minute for conversion 1 minute = 60 seconds * 1000 ms
const msInAMinute = 1000 * 60; // 60 seconds * 1000ms
const differenceInMinutes = Math.round(differenceInMs / msInAMinute);
return differenceInMinutes <= 5;
@@ -141,4 +117,32 @@ export class UnifiedUpgradePromptService {
// Return the result or null if the dialog was dismissed without a result
return result || null;
}
/**
* Checks if the user has any organization associated with their account
* @param userId User ID to check
* @returns Promise that resolves to true if user has any organizations, false otherwise
*/
private async hasOrganizations(userId: UserId): Promise<boolean> {
// Wait for sync to complete to ensure organizations are fully loaded
// Also force a sync to ensure we have the latest data
await this.syncService.fullSync(false);
// Wait for the sync to complete with timeout to prevent hanging
await firstValueFrom(
this.syncService.lastSync$(userId).pipe(
filter((lastSync) => lastSync !== null),
take(1),
timeout(30000), // 30 second timeout
),
);
// Check if user has any organization membership (any status including pending)
// Try using memberOrganizations$ which might have different filtering logic
const memberOrganizations = await firstValueFrom(
this.organizationService.memberOrganizations$(userId),
);
return memberOrganizations.length > 0;
}
}

View File

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

View File

@@ -1,12 +1,12 @@
import {
AfterViewChecked,
AfterViewInit,
Component,
DestroyRef,
input,
OnInit,
output,
signal,
ViewChild,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
@@ -19,6 +19,8 @@ import {
catchError,
of,
combineLatest,
map,
shareReplay,
} from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
@@ -96,7 +98,8 @@ export type UpgradePaymentParams = {
providers: [UpgradePaymentService],
templateUrl: "./upgrade-payment.component.html",
})
export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
export class UpgradePaymentComponent implements OnInit, AfterViewInit {
private readonly INITIAL_TAX_VALUE = 0;
protected readonly selectedPlanId = input.required<PersonalSubscriptionPricingTierId>();
protected readonly account = input.required<Account>();
protected goBack = output<void>();
@@ -104,12 +107,8 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
protected selectedPlan: PlanDetails | null = null;
protected hasEnoughAccountCredit$!: Observable<boolean>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent;
readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent);
readonly cartSummaryComponent = viewChild.required(CartSummaryComponent);
protected formGroup = new FormGroup({
organizationName: new FormControl<string>("", [Validators.required]),
@@ -118,12 +117,11 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
});
protected readonly loading = signal(true);
private cartSummaryConfigured = false;
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
// Cart Summary data
protected passwordManager!: LineItem;
protected estimatedTax = 0;
protected estimatedTax$!: Observable<number>;
// Display data
protected upgradeToMessage = "";
@@ -165,49 +163,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
);
this.estimatedTax = 0;
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
this.formGroup.controls.billingAddress.valueChanges
.pipe(
debounceTime(1000),
// Only proceed when form has required values
switchMap(() => this.refreshSalesTax$()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((tax) => {
this.estimatedTax = tax;
});
// Check if user has enough account credit for the purchase
this.hasEnoughAccountCredit$ = combineLatest([
this.upgradePaymentService.accountCredit$,
this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)),
]).pipe(
switchMap(([credit, formValue]) => {
const selectedPaymentType = formValue.paymentForm?.type;
if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) {
return of(true); // Not using account credit, so this check doesn't apply
}
return credit ? of(credit >= this.cartSummaryComponent.total()) : of(false);
}),
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
// Only proceed when form has required values
switchMap(() => this.refreshSalesTax$()),
);
this.loading.set(false);
}
ngAfterViewChecked(): void {
// Configure cart summary only once when it becomes available
if (this.cartSummaryComponent && !this.cartSummaryConfigured) {
this.cartSummaryComponent.isExpanded.set(false);
this.cartSummaryConfigured = true;
}
ngAfterViewInit(): void {
const cartSummaryComponent = this.cartSummaryComponent();
cartSummaryComponent.isExpanded.set(false);
this.hasEnoughAccountCredit$ = combineLatest([
cartSummaryComponent.total$,
this.upgradePaymentService.accountCredit$,
this.formGroup.controls.paymentForm.valueChanges.pipe(
startWith(this.formGroup.controls.paymentForm.value),
),
]).pipe(
map(([total, credit, currentFormValue]) => {
const selectedPaymentType = currentFormValue?.type;
if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) {
return true; // Not using account credit, so this check doesn't apply
}
return credit ? credit >= total : false;
}),
shareReplay({ bufferSize: 1, refCount: true }), // Cache the latest for two async pipes
);
}
protected get isPremiumPlan(): boolean {
@@ -252,7 +243,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
};
protected isFormValid(): boolean {
return this.formGroup.valid && this.paymentComponent?.validate();
return this.formGroup.valid && this.paymentComponent().validate();
}
private async processUpgrade(): Promise<UpgradePaymentResult> {
@@ -335,17 +326,19 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
return { type: NonTokenizablePaymentMethods.accountCredit };
}
return await this.paymentComponent?.tokenize();
return await this.paymentComponent().tokenize();
}
// Create an observable for tax calculation
private refreshSalesTax$(): Observable<number> {
if (this.formGroup.invalid || !this.selectedPlan) {
return of(0);
return of(this.INITIAL_TAX_VALUE);
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
if (!billingAddress.country || !billingAddress.postalCode) {
return of(this.INITIAL_TAX_VALUE);
}
return from(
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress),
).pipe(
@@ -355,7 +348,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
variant: "error",
message: this.i18nService.t("taxCalculationError"),
});
return of(0); // Return default value on error
return of(this.INITIAL_TAX_VALUE); // Return default value on error
}),
);
}

View File

@@ -151,14 +151,17 @@ describe("RiskInsightsEncryptionService", () => {
describe("decryptRiskInsightsReport", () => {
it("should decrypt data and return original object", async () => {
// Arrange: setup our mocks
// Arrange: setup our mocks with valid data structures
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
// act: call the decrypt method - with any params
// actual decryption does not happen here,
// we just want to ensure the method calls are correct
// Mock decryption to return valid data for each call
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData))
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
// act: call the decrypt method
const result = await service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
@@ -169,33 +172,37 @@ describe("RiskInsightsEncryptionService", () => {
expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey);
expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3);
// Mock decrypt returns JSON.stringify(testData)
// Verify decrypted data matches the mocked valid data
expect(result).toEqual({
reportData: testData,
summaryData: testData,
applicationData: testData,
reportData: mockReportData,
summaryData: mockSummaryData,
applicationData: mockApplicationData,
});
});
it("should invoke data type validation method during decryption", async () => {
// Arrange: setup our mocks
// Arrange: setup our mocks with valid data structures
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
// act: call the decrypt method - with any params
// actual decryption does not happen here,
// we just want to ensure the method calls are correct
// Mock decryption to return valid data for each call
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData))
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
// act: call the decrypt method
const result = await service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
);
// Verify that validation passed and returned the correct data
expect(result).toEqual({
reportData: testData,
summaryData: testData,
applicationData: testData,
reportData: mockReportData,
summaryData: mockSummaryData,
applicationData: mockApplicationData,
});
});
@@ -211,7 +218,7 @@ describe("RiskInsightsEncryptionService", () => {
).rejects.toEqual(Error("Organization key not found"));
});
it("should return null if decrypt throws", async () => {
it("should throw if decrypt throws", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail"));
@@ -224,5 +231,106 @@ describe("RiskInsightsEncryptionService", () => {
),
).rejects.toEqual(Error("fail"));
});
it("should throw error when report data validation fails", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
// Mock decryption to return invalid data
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify([{ invalid: "data" }])) // invalid report data
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Report data validation failed.*This may indicate data corruption or tampering/,
);
});
it("should throw error when summary data validation fails", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
// Clear and reset the mock
mockEncryptService.decryptString.mockReset();
// Mock decryption - report data should succeed, summary should fail
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
.mockResolvedValueOnce(JSON.stringify({ invalid: "summary" })) // invalid summary data - fails here
.mockResolvedValueOnce(JSON.stringify(mockApplicationData)); // won't be called but prevents fallback
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Summary data validation failed.*This may indicate data corruption or tampering/,
);
});
it("should throw error when application data validation fails", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
// Clear and reset the mock
mockEncryptService.decryptString.mockReset();
// Mock decryption - report and summary should succeed, application should fail
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
.mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid
.mockResolvedValueOnce(JSON.stringify([{ invalid: "application" }])); // invalid app data
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Application data validation failed.*This may indicate data corruption or tampering/,
);
});
it("should throw error for invalid date in application data", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
const invalidApplicationData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: "invalid-date-string",
},
];
// Clear and reset the mock
mockEncryptService.decryptString.mockReset();
// Mock decryption - report and summary succeed, application with invalid date fails
mockEncryptService.decryptString
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
.mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid
.mockResolvedValueOnce(JSON.stringify(invalidApplicationData)); // invalid date
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
),
).rejects.toThrow(
/Application data validation failed.*This may indicate data corruption or tampering/,
);
});
});
});

View File

@@ -10,14 +10,20 @@ import { LogService } from "@bitwarden/logging";
import { createNewSummaryData } from "../../helpers";
import {
DecryptedReportData,
EncryptedReportData,
EncryptedDataWithKey,
ApplicationHealthReportDetail,
OrganizationReportSummary,
DecryptedReportData,
EncryptedDataWithKey,
EncryptedReportData,
OrganizationReportApplication,
OrganizationReportSummary,
} from "../../models";
import {
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
} from "./risk-insights-type-guards";
export class RiskInsightsEncryptionService {
constructor(
private keyService: KeyService,
@@ -182,11 +188,16 @@ export class RiskInsightsEncryptionService {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as ApplicationHealthReportDetail[];
// Validate parsed data structure with runtime type guards
return validateApplicationHealthReportDetailArray(parsedData);
} catch (error: unknown) {
// Log detailed error for debugging
this.logService.error("[RiskInsightsEncryptionService] Failed to decrypt report", error);
return [];
// Always throw generic message to prevent information disclosure
// Original error with detailed validation info is logged, not exposed to caller
throw new Error(
"Report data validation failed. This may indicate data corruption or tampering.",
);
}
}
@@ -202,14 +213,19 @@ export class RiskInsightsEncryptionService {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as OrganizationReportSummary;
// Validate parsed data structure with runtime type guards
return validateOrganizationReportSummary(parsedData);
} catch (error: unknown) {
// Log detailed error for debugging
this.logService.error(
"[RiskInsightsEncryptionService] Failed to decrypt report summary",
error,
);
return createNewSummaryData();
// Always throw generic message to prevent information disclosure
// Original error with detailed validation info is logged, not exposed to caller
throw new Error(
"Summary data validation failed. This may indicate data corruption or tampering.",
);
}
}
@@ -225,14 +241,19 @@ export class RiskInsightsEncryptionService {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as OrganizationReportApplication[];
// Validate parsed data structure with runtime type guards
return validateOrganizationReportApplicationArray(parsedData);
} catch (error: unknown) {
// Log detailed error for debugging
this.logService.error(
"[RiskInsightsEncryptionService] Failed to decrypt report applications",
error,
);
return [];
// Always throw generic message to prevent information disclosure
// Original error with detailed validation info is logged, not exposed to caller
throw new Error(
"Application data validation failed. This may indicate data corruption or tampering.",
);
}
}
}

View File

@@ -0,0 +1,668 @@
import { MemberDetails } from "../../models";
import {
isApplicationHealthReportDetail,
isMemberDetails,
isOrganizationReportApplication,
isOrganizationReportSummary,
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
} from "./risk-insights-type-guards";
describe("Risk Insights Type Guards", () => {
describe("validateApplicationHealthReportDetailArray", () => {
it("should validate valid ApplicationHealthReportDetail array", () => {
const validData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1", "cipher-2"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [
{
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
},
],
atRiskMemberDetails: [
{
userGuid: "user-2",
userName: "Jane Doe",
email: "jane@example.com",
cipherId: "cipher-2",
},
],
cipherIds: ["cipher-1", "cipher-2"],
},
];
expect(() => validateApplicationHealthReportDetailArray(validData)).not.toThrow();
expect(validateApplicationHealthReportDetailArray(validData)).toEqual(validData);
});
it("should throw error for non-array input", () => {
expect(() => validateApplicationHealthReportDetailArray("not an array")).toThrow(
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
);
});
it("should throw error for array with invalid elements", () => {
const invalidData = [
{
applicationName: "Test App",
// missing required fields
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/,
);
});
it("should throw error for array with multiple invalid elements", () => {
const invalidData = [
{ applicationName: "App 1" }, // invalid
{
applicationName: "App 2",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
}, // valid
{ applicationName: "App 3" }, // invalid
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/,
);
});
it("should throw error for invalid memberDetails", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [{ userGuid: "user-1" }] as any, // missing required fields
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data/,
);
});
it("should throw error for empty string in atRiskCipherIds", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1", "", "cipher-3"], // empty string
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data/,
);
});
it("should throw error for empty string in cipherIds", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["", "cipher-2"], // empty string
},
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data/,
);
});
});
describe("validateOrganizationReportSummary", () => {
it("should validate valid OrganizationReportSummary", () => {
const validData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1", "app-2"],
};
expect(() => validateOrganizationReportSummary(validData)).not.toThrow();
expect(validateOrganizationReportSummary(validData)).toEqual(validData);
});
it("should throw error for missing totalMemberCount", () => {
const invalidData = {
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary: missing or invalid fields: totalMemberCount \(number\)/,
);
});
it("should throw error for multiple missing fields", () => {
const invalidData = {
totalMemberCount: 10,
// missing multiple fields
newApplications: ["app-1"],
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary: missing or invalid fields:.*totalApplicationCount/,
);
});
it("should throw error for invalid field types", () => {
const invalidData = {
totalMemberCount: "10", // should be number
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary/,
);
});
it("should throw error for non-array newApplications", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: "not-an-array",
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary.*newApplications/,
);
});
it("should throw error for empty string in newApplications", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1", "", "app-3"], // empty string
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary/,
);
});
});
describe("validateOrganizationReportApplicationArray", () => {
it("should validate valid OrganizationReportApplication array", () => {
const validData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: null,
},
{
applicationName: "Another App",
isCritical: false,
reviewedDate: new Date("2024-01-01"),
},
];
expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow();
const result = validateOrganizationReportApplicationArray(validData);
expect(result[0].applicationName).toBe("Test App");
expect(result[1].reviewedDate).toBeInstanceOf(Date);
});
it("should convert string dates to Date objects", () => {
const validData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: "2024-01-01T00:00:00.000Z",
},
];
const result = validateOrganizationReportApplicationArray(validData);
expect(result[0].reviewedDate).toBeInstanceOf(Date);
expect(result[0].reviewedDate?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
});
it("should throw error for invalid date strings", () => {
const invalidData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: "invalid-date",
},
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
"Invalid date string: invalid-date",
);
});
it("should throw error for non-array input", () => {
expect(() => validateOrganizationReportApplicationArray("not an array")).toThrow(
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
);
});
it("should throw error for array with invalid elements", () => {
const invalidData = [
{
applicationName: "Test App",
reviewedDate: null as any,
// missing isCritical field
} as any,
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/,
);
});
it("should throw error for invalid field types", () => {
const invalidData = [
{
applicationName: 123 as any, // should be string
isCritical: true,
reviewedDate: null as any,
} as any,
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
/Invalid application data/,
);
});
it("should accept null reviewedDate", () => {
const validData = [
{
applicationName: "Test App",
isCritical: true,
reviewedDate: null as any,
},
];
expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow();
const result = validateOrganizationReportApplicationArray(validData);
expect(result[0].reviewedDate).toBeNull();
});
});
// Tests for exported type guard functions
describe("isMemberDetails", () => {
it("should return true for valid MemberDetails", () => {
const validData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
};
expect(isMemberDetails(validData)).toBe(true);
});
it("should return false for empty userGuid", () => {
const invalidData = {
userGuid: "",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for empty userName", () => {
const invalidData = {
userGuid: "user-1",
userName: "",
email: "john@example.com",
cipherId: "cipher-1",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for empty email", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "",
cipherId: "cipher-1",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for empty cipherId", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
unexpectedProperty: "should fail",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for prototype pollution attempts", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
__proto__: { malicious: "payload" },
};
expect(isMemberDetails(invalidData)).toBe(false);
});
});
describe("isApplicationHealthReportDetail", () => {
it("should return true for valid ApplicationHealthReportDetail", () => {
const validData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(validData)).toBe(true);
});
it("should return false for empty applicationName", () => {
const invalidData = {
applicationName: "",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for NaN passwordCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: NaN,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for Infinity passwordCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: Infinity,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for negative passwordCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: -5,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for negative memberCount", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: -1,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
injectedProperty: "malicious",
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
});
describe("isOrganizationReportSummary", () => {
it("should return true for valid OrganizationReportSummary", () => {
const validData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(validData)).toBe(true);
});
it("should return false for NaN totalMemberCount", () => {
const invalidData = {
totalMemberCount: NaN,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for Infinity totalApplicationCount", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: Infinity,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for negative totalAtRiskMemberCount", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: -1,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: ["app-1"],
extraField: "should be rejected",
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
});
describe("isOrganizationReportApplication", () => {
it("should return true for valid OrganizationReportApplication", () => {
const validData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
};
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return false for empty applicationName", () => {
const invalidData = {
applicationName: "",
isCritical: true,
reviewedDate: null as Date | null,
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
it("should return true for Date reviewedDate", () => {
const validData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: new Date(),
};
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return true for string reviewedDate", () => {
const validData = {
applicationName: "Test App",
isCritical: false,
reviewedDate: "2024-01-01",
};
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
injectedProperty: "malicious",
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
it("should return false for prototype pollution attempts via __proto__", () => {
const invalidData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
__proto__: { polluted: true },
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
});
});

View File

@@ -0,0 +1,404 @@
import {
ApplicationHealthReportDetail,
MemberDetails,
OrganizationReportApplication,
OrganizationReportSummary,
} from "../../models";
/**
* Security limits for validation (prevent DoS attacks and ensure reasonable data sizes)
*/
const MAX_STRING_LENGTH = 1000; // Reasonable limit for names, emails, GUIDs
const MAX_ARRAY_LENGTH = 50000; // Reasonable limit for report arrays
const MAX_COUNT = 10000000; // 10 million - reasonable upper bound for count fields
/**
* Type guard to validate MemberDetails structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isMemberDetails(obj: any): obj is MemberDetails {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = ["userGuid", "userName", "email", "cipherId"];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.userGuid === "string" &&
obj.userGuid.length > 0 &&
obj.userGuid.length <= MAX_STRING_LENGTH &&
typeof obj.userName === "string" &&
obj.userName.length > 0 &&
obj.userName.length <= MAX_STRING_LENGTH &&
typeof obj.email === "string" &&
obj.email.length > 0 &&
obj.email.length <= MAX_STRING_LENGTH &&
typeof obj.cipherId === "string" &&
obj.cipherId.length > 0 &&
obj.cipherId.length <= MAX_STRING_LENGTH
);
}
/**
* Type guard to validate ApplicationHealthReportDetail structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isApplicationHealthReportDetail(obj: any): obj is ApplicationHealthReportDetail {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = [
"applicationName",
"passwordCount",
"atRiskPasswordCount",
"atRiskCipherIds",
"memberCount",
"atRiskMemberCount",
"memberDetails",
"atRiskMemberDetails",
"cipherIds",
];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.applicationName === "string" &&
obj.applicationName.length > 0 &&
obj.applicationName.length <= MAX_STRING_LENGTH &&
typeof obj.passwordCount === "number" &&
Number.isFinite(obj.passwordCount) &&
Number.isSafeInteger(obj.passwordCount) &&
obj.passwordCount >= 0 &&
obj.passwordCount <= MAX_COUNT &&
typeof obj.atRiskPasswordCount === "number" &&
Number.isFinite(obj.atRiskPasswordCount) &&
Number.isSafeInteger(obj.atRiskPasswordCount) &&
obj.atRiskPasswordCount >= 0 &&
obj.atRiskPasswordCount <= MAX_COUNT &&
Array.isArray(obj.atRiskCipherIds) &&
obj.atRiskCipherIds.length <= MAX_ARRAY_LENGTH &&
obj.atRiskCipherIds.every(
(id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH,
) &&
typeof obj.memberCount === "number" &&
Number.isFinite(obj.memberCount) &&
Number.isSafeInteger(obj.memberCount) &&
obj.memberCount >= 0 &&
obj.memberCount <= MAX_COUNT &&
typeof obj.atRiskMemberCount === "number" &&
Number.isFinite(obj.atRiskMemberCount) &&
Number.isSafeInteger(obj.atRiskMemberCount) &&
obj.atRiskMemberCount >= 0 &&
obj.atRiskMemberCount <= MAX_COUNT &&
Array.isArray(obj.memberDetails) &&
obj.memberDetails.length <= MAX_ARRAY_LENGTH &&
obj.memberDetails.every(isMemberDetails) &&
Array.isArray(obj.atRiskMemberDetails) &&
obj.atRiskMemberDetails.length <= MAX_ARRAY_LENGTH &&
obj.atRiskMemberDetails.every(isMemberDetails) &&
Array.isArray(obj.cipherIds) &&
obj.cipherIds.length <= MAX_ARRAY_LENGTH &&
obj.cipherIds.every(
(id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH,
)
);
}
/**
* Type guard to validate OrganizationReportSummary structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isOrganizationReportSummary(obj: any): obj is OrganizationReportSummary {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = [
"totalMemberCount",
"totalApplicationCount",
"totalAtRiskMemberCount",
"totalAtRiskApplicationCount",
"totalCriticalApplicationCount",
"totalCriticalMemberCount",
"totalCriticalAtRiskMemberCount",
"totalCriticalAtRiskApplicationCount",
"newApplications",
];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.totalMemberCount === "number" &&
Number.isFinite(obj.totalMemberCount) &&
Number.isSafeInteger(obj.totalMemberCount) &&
obj.totalMemberCount >= 0 &&
obj.totalMemberCount <= MAX_COUNT &&
typeof obj.totalApplicationCount === "number" &&
Number.isFinite(obj.totalApplicationCount) &&
Number.isSafeInteger(obj.totalApplicationCount) &&
obj.totalApplicationCount >= 0 &&
obj.totalApplicationCount <= MAX_COUNT &&
typeof obj.totalAtRiskMemberCount === "number" &&
Number.isFinite(obj.totalAtRiskMemberCount) &&
Number.isSafeInteger(obj.totalAtRiskMemberCount) &&
obj.totalAtRiskMemberCount >= 0 &&
obj.totalAtRiskMemberCount <= MAX_COUNT &&
typeof obj.totalAtRiskApplicationCount === "number" &&
Number.isFinite(obj.totalAtRiskApplicationCount) &&
Number.isSafeInteger(obj.totalAtRiskApplicationCount) &&
obj.totalAtRiskApplicationCount >= 0 &&
obj.totalAtRiskApplicationCount <= MAX_COUNT &&
typeof obj.totalCriticalApplicationCount === "number" &&
Number.isFinite(obj.totalCriticalApplicationCount) &&
Number.isSafeInteger(obj.totalCriticalApplicationCount) &&
obj.totalCriticalApplicationCount >= 0 &&
obj.totalCriticalApplicationCount <= MAX_COUNT &&
typeof obj.totalCriticalMemberCount === "number" &&
Number.isFinite(obj.totalCriticalMemberCount) &&
Number.isSafeInteger(obj.totalCriticalMemberCount) &&
obj.totalCriticalMemberCount >= 0 &&
obj.totalCriticalMemberCount <= MAX_COUNT &&
typeof obj.totalCriticalAtRiskMemberCount === "number" &&
Number.isFinite(obj.totalCriticalAtRiskMemberCount) &&
Number.isSafeInteger(obj.totalCriticalAtRiskMemberCount) &&
obj.totalCriticalAtRiskMemberCount >= 0 &&
obj.totalCriticalAtRiskMemberCount <= MAX_COUNT &&
typeof obj.totalCriticalAtRiskApplicationCount === "number" &&
Number.isFinite(obj.totalCriticalAtRiskApplicationCount) &&
Number.isSafeInteger(obj.totalCriticalAtRiskApplicationCount) &&
obj.totalCriticalAtRiskApplicationCount >= 0 &&
obj.totalCriticalAtRiskApplicationCount <= MAX_COUNT &&
Array.isArray(obj.newApplications) &&
obj.newApplications.length <= MAX_ARRAY_LENGTH &&
obj.newApplications.every(
(app: any) => typeof app === "string" && app.length > 0 && app.length <= MAX_STRING_LENGTH,
)
);
}
/**
* Type guard to validate OrganizationReportApplication structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isOrganizationReportApplication(obj: any): obj is OrganizationReportApplication {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = ["applicationName", "isCritical", "reviewedDate"];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.applicationName === "string" &&
obj.applicationName.length > 0 &&
obj.applicationName.length <= MAX_STRING_LENGTH &&
typeof obj.isCritical === "boolean" &&
(obj.reviewedDate === null ||
obj.reviewedDate instanceof Date ||
typeof obj.reviewedDate === "string")
);
}
/**
* Validates and returns an array of ApplicationHealthReportDetail
* @throws Error if validation fails
*/
export function validateApplicationHealthReportDetailArray(
data: any,
): ApplicationHealthReportDetail[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
);
}
if (data.length > MAX_ARRAY_LENGTH) {
throw new Error(
`Invalid report data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isApplicationHealthReportDetail(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`,
);
}
return data as ApplicationHealthReportDetail[];
}
/**
* Validates and returns OrganizationReportSummary
* @throws Error if validation fails
*/
export function validateOrganizationReportSummary(data: any): OrganizationReportSummary {
if (!isOrganizationReportSummary(data)) {
const missingFields: string[] = [];
if (typeof data?.totalMemberCount !== "number") {
missingFields.push("totalMemberCount (number)");
}
if (typeof data?.totalApplicationCount !== "number") {
missingFields.push("totalApplicationCount (number)");
}
if (typeof data?.totalAtRiskMemberCount !== "number") {
missingFields.push("totalAtRiskMemberCount (number)");
}
if (typeof data?.totalAtRiskApplicationCount !== "number") {
missingFields.push("totalAtRiskApplicationCount (number)");
}
if (typeof data?.totalCriticalApplicationCount !== "number") {
missingFields.push("totalCriticalApplicationCount (number)");
}
if (typeof data?.totalCriticalMemberCount !== "number") {
missingFields.push("totalCriticalMemberCount (number)");
}
if (typeof data?.totalCriticalAtRiskMemberCount !== "number") {
missingFields.push("totalCriticalAtRiskMemberCount (number)");
}
if (typeof data?.totalCriticalAtRiskApplicationCount !== "number") {
missingFields.push("totalCriticalAtRiskApplicationCount (number)");
}
if (!Array.isArray(data?.newApplications)) {
missingFields.push("newApplications (string[])");
}
throw new Error(
`Invalid OrganizationReportSummary: ${missingFields.length > 0 ? `missing or invalid fields: ${missingFields.join(", ")}` : "structure validation failed"}`,
);
}
return data as OrganizationReportSummary;
}
/**
* Validates and returns an array of OrganizationReportApplication
* @throws Error if validation fails
*/
export function validateOrganizationReportApplicationArray(
data: any,
): OrganizationReportApplication[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
);
}
if (data.length > MAX_ARRAY_LENGTH) {
throw new Error(
`Invalid application data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isOrganizationReportApplication(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`,
);
}
// Convert string dates to Date objects for reviewedDate
return data.map((item) => ({
...item,
reviewedDate: item.reviewedDate
? item.reviewedDate instanceof Date
? item.reviewedDate
: (() => {
const date = new Date(item.reviewedDate);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date string: ${item.reviewedDate}`);
}
return date;
})()
: null,
})) as OrganizationReportApplication[];
}

View File

@@ -175,6 +175,65 @@ export class RiskInsightsDataService {
}
};
setDrawerForCriticalAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.criticalReportResults$);
if (!reportResults?.reportData) {
return;
}
// Generate at-risk member list from critical applications
const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData);
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskMembers,
atRiskMemberDetails,
appAtRiskMembers: null,
atRiskAppDetails: null,
});
}
};
setDrawerForCriticalAtRiskApps = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.criticalReportResults$);
if (!reportResults?.reportData) {
return;
}
// Filter critical applications for those with at-risk passwords
const criticalAtRiskApps = reportResults.reportData
.filter((app) => app.atRiskPasswordCount > 0)
.map((app) => ({
applicationName: app.applicationName,
atRiskPasswordCount: app.atRiskPasswordCount,
}));
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskApps,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: criticalAtRiskApps,
});
}
};
// ------------------------------ Critical application methods --------------
saveCriticalApplications(selectedUrls: string[]) {
return this.orchestrator.saveCriticalApplications$(selectedUrls);

View File

@@ -23,11 +23,11 @@
</button>
</div>
}
@if (showNavigationLink && !buttonText) {
@if (showActionLink && !buttonText) {
<div class="tw-flex tw-items-baseline tw-mt-4 tw-gap-2">
<p bitTypography="body1">
<a bitLink (click)="navigateToLink(navigationLink)" rel="noreferrer">
{{ navigationText }}
<a bitLink href="#" (click)="onActionClick(); $event.preventDefault()" rel="noreferrer">
{{ actionText }}
</a>
</p>
</div>

View File

@@ -37,25 +37,14 @@ export class ActivityCardComponent {
@Input() metricDescription: string = "";
/**
* The link to navigate to for more information
* The text to display for the action link
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() navigationLink: string = "";
@Input() actionText: string = "";
/**
* The text to display for the navigation link
* Show action link
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() navigationText: string = "";
/**
* Show Navigation link
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() showNavigationLink: boolean = false;
@Input() showActionLink: boolean = false;
/**
* Icon class to display next to metrics (e.g., "bwi-exclamation-triangle").
@@ -86,13 +75,18 @@ export class ActivityCardComponent {
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() buttonClick = new EventEmitter<void>();
constructor(private router: Router) {}
/**
* Event emitted when action link is clicked
*/
@Output() actionClick = new EventEmitter<void>();
navigateToLink = async (navigationLink: string) => {
await this.router.navigateByUrl(navigationLink);
};
constructor(private router: Router) {}
onButtonClick = () => {
this.buttonClick.emit();
};
onActionClick = () => {
this.actionClick.emit();
};
}

View File

@@ -13,9 +13,9 @@
[title]="'atRiskMembers' | i18n"
[cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount"
[metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n"
navigationText="{{ 'viewAtRiskMembers' | i18n }}"
navigationLink="{{ getLinkForRiskInsightsTab(RiskInsightsTabType.AllApps) }}"
[showNavigationLink]="totalCriticalAppsAtRiskMemberCount > 0"
actionText="{{ 'viewAtRiskMembers' | i18n }}"
[showActionLink]="totalCriticalAppsAtRiskMemberCount > 0"
(actionClick)="onViewAtRiskMembers()"
>
</dirt-activity-card>
</li>
@@ -35,9 +35,9 @@
: ('criticalApplicationsAreAtRisk'
| i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount)
"
navigationText="{{ 'viewAtRiskApplications' | i18n }}"
navigationLink="{{ getLinkForRiskInsightsTab(RiskInsightsTabType.CriticalApps) }}"
[showNavigationLink]="totalCriticalAppsAtRiskCount > 0"
actionText="{{ 'viewAtRiskApplications' | i18n }}"
[showActionLink]="totalCriticalAppsAtRiskCount > 0"
(actionClick)="onViewAtRiskApplications()"
>
</dirt-activity-card>
</li>

View File

@@ -15,7 +15,6 @@ import { getById } from "@bitwarden/common/platform/misc";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { RiskInsightsTabType } from "../models/risk-insights.models";
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
import { ActivityCardComponent } from "./activity-card.component";
@@ -82,15 +81,6 @@ export class AllActivityComponent implements OnInit {
}
}
get RiskInsightsTabType() {
return RiskInsightsTabType;
}
getLinkForRiskInsightsTab(tabIndex: RiskInsightsTabType): string {
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
return `/organizations/${organizationId}/access-intelligence/risk-insights?tabIndex=${tabIndex}`;
}
/**
* Handles the review new applications button click.
* Opens a dialog showing the list of new applications that can be marked as critical.
@@ -102,4 +92,20 @@ export class AllActivityComponent implements OnInit {
await firstValueFrom(dialogRef.closed);
};
/**
* Handles the "View at-risk members" link click.
* Opens the at-risk members drawer for critical applications only.
*/
onViewAtRiskMembers = async () => {
await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers");
};
/**
* Handles the "View at-risk applications" link click.
* Opens the at-risk applications drawer for critical applications only.
*/
onViewAtRiskApplications = async () => {
await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications");
};
}

View File

@@ -0,0 +1,42 @@
import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { UserId } from "@bitwarden/user-core";
import { AutoConfirmState } from "../models/auto-confirm-state.model";
export abstract class AutomaticUserConfirmationService {
/**
* @param userId
* @returns Observable<AutoConfirmState> an observable with the Auto Confirm user state for the provided userId.
**/
abstract configuration$(userId: UserId): Observable<AutoConfirmState>;
/**
* Upserts the existing user state with a new configuration.
* @param userId
* @param config The new AutoConfirmState to upsert into the user state for the provided userId.
**/
abstract upsert(userId: UserId, config: AutoConfirmState): Promise<void>;
/**
* This will check if the feature is enabled, the organization plan feature UseAutomaticUserConfirmation is enabled
* and the the provided user has admin/owner/manage custom permission role.
* @param userId
* @returns Observable<boolean> an observable with a boolean telling us if the provided user may confgure the auto confirm feature.
**/
abstract canManageAutoConfirm$(
userId: UserId,
organizationId: OrganizationId,
): Observable<boolean>;
/**
* Calls the API endpoint to initiate automatic user confirmation.
* @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks.
* @param confirmingUserId The userId of the user being confirmed.
* @param organization the organization the user is being auto confirmed to.
**/
abstract autoConfirmUser(
userId: UserId,
confirmingUserId: UserId,
organization: Organization,
): Promise<void>;
}

View File

@@ -0,0 +1 @@
export * from "./auto-confirm.service.abstraction";

View File

@@ -0,0 +1,3 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";

View File

@@ -1,4 +1,4 @@
import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state";
import { AUTO_CONFIRM, UserKeyDefinition } from "@bitwarden/state";
export class AutoConfirmState {
enabled: boolean;

View File

@@ -0,0 +1 @@
export * from "./auto-confirm-state.model";

View File

@@ -0,0 +1,382 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
DefaultOrganizationUserService,
OrganizationUserApiService,
OrganizationUserConfirmRequest,
} from "../../organization-user";
import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model";
import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service";
describe("DefaultAutomaticUserConfirmationService", () => {
let service: DefaultAutomaticUserConfirmationService;
let configService: jest.Mocked<ConfigService>;
let apiService: jest.Mocked<ApiService>;
let organizationUserService: jest.Mocked<DefaultOrganizationUserService>;
let stateProvider: FakeStateProvider;
let organizationService: jest.Mocked<InternalOrganizationServiceAbstraction>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
const mockUserId = Utils.newGuid() as UserId;
const mockConfirmingUserId = Utils.newGuid() as UserId;
const mockOrganizationId = Utils.newGuid() as OrganizationId;
let mockOrganization: Organization;
beforeEach(() => {
configService = {
getFeatureFlag$: jest.fn(),
} as any;
apiService = {
getUserPublicKey: jest.fn(),
} as any;
organizationUserService = {
buildConfirmRequest: jest.fn(),
} as any;
stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId));
organizationService = {
organizations$: jest.fn(),
} as any;
organizationUserApiService = {
postOrganizationUserConfirm: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
DefaultAutomaticUserConfirmationService,
{ provide: ConfigService, useValue: configService },
{ provide: ApiService, useValue: apiService },
{ provide: DefaultOrganizationUserService, useValue: organizationUserService },
{ provide: "StateProvider", useValue: stateProvider },
{
provide: InternalOrganizationServiceAbstraction,
useValue: organizationService,
},
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
],
});
service = new DefaultAutomaticUserConfirmationService(
configService,
apiService,
organizationUserService,
stateProvider,
organizationService,
organizationUserApiService,
);
const mockOrgData = new OrganizationData({} as any, {} as any);
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = true;
const permissions = new PermissionsApi();
permissions.manageUsers = true;
mockOrgData.permissions = permissions;
mockOrganization = new Organization(mockOrgData);
});
describe("configuration$", () => {
it("should return default AutoConfirmState when no state exists", async () => {
const config$ = service.configuration$(mockUserId);
const config = await firstValueFrom(config$);
expect(config).toBeInstanceOf(AutoConfirmState);
expect(config.enabled).toBe(false);
expect(config.showSetupDialog).toBe(true);
});
it("should return stored AutoConfirmState when state exists", async () => {
const expectedConfig = new AutoConfirmState();
expectedConfig.enabled = true;
expectedConfig.showSetupDialog = false;
expectedConfig.showBrowserNotification = true;
await stateProvider.setUserState(
AUTO_CONFIRM_STATE,
{ [mockUserId]: expectedConfig },
mockUserId,
);
const config$ = service.configuration$(mockUserId);
const config = await firstValueFrom(config$);
expect(config.enabled).toBe(true);
expect(config.showSetupDialog).toBe(false);
expect(config.showBrowserNotification).toBe(true);
});
it("should emit updates when state changes", async () => {
const config$ = service.configuration$(mockUserId);
const configs: AutoConfirmState[] = [];
const subscription = config$.subscribe((config) => configs.push(config));
expect(configs[0].enabled).toBe(false);
const newConfig = new AutoConfirmState();
newConfig.enabled = true;
await stateProvider.setUserState(AUTO_CONFIRM_STATE, { [mockUserId]: newConfig }, mockUserId);
expect(configs.length).toBeGreaterThan(1);
expect(configs[configs.length - 1].enabled).toBe(true);
subscription.unsubscribe();
});
});
describe("upsert", () => {
it("should store new configuration for user", async () => {
const newConfig = new AutoConfirmState();
newConfig.enabled = true;
newConfig.showSetupDialog = false;
await service.upsert(mockUserId, newConfig);
const storedState = await firstValueFrom(
stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$,
);
expect(storedState != null);
expect(storedState![mockUserId]).toEqual(newConfig);
});
it("should update existing configuration for user", async () => {
const initialConfig = new AutoConfirmState();
initialConfig.enabled = false;
await service.upsert(mockUserId, initialConfig);
const updatedConfig = new AutoConfirmState();
updatedConfig.enabled = true;
updatedConfig.showSetupDialog = false;
await service.upsert(mockUserId, updatedConfig);
const storedState = await firstValueFrom(
stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$,
);
expect(storedState != null);
expect(storedState![mockUserId].enabled).toBe(true);
expect(storedState![mockUserId].showSetupDialog).toBe(false);
});
it("should preserve other user configurations when updating", async () => {
const otherUserId = Utils.newGuid() as UserId;
const otherConfig = new AutoConfirmState();
otherConfig.enabled = true;
await stateProvider.setUserState(
AUTO_CONFIRM_STATE,
{ [otherUserId]: otherConfig },
mockUserId,
);
const newConfig = new AutoConfirmState();
newConfig.enabled = false;
await service.upsert(mockUserId, newConfig);
const storedState = await firstValueFrom(
stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$,
);
expect(storedState != null);
expect(storedState![mockUserId]).toEqual(newConfig);
expect(storedState![otherUserId]).toEqual(otherConfig);
});
});
describe("canManageAutoConfirm$", () => {
beforeEach(() => {
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
organizationService.organizations$.mockReturnValue(organizations$);
});
it("should return true when feature flag is enabled and organization allows management", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(true);
});
it("should return false when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
});
it("should return false when organization canManageUsers is false", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
// Create organization without manageUsers permission
const mockOrgData = new OrganizationData({} as any, {} as any);
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = true;
const permissions = new PermissionsApi();
permissions.manageUsers = false;
mockOrgData.permissions = permissions;
const orgWithoutManageUsers = new Organization(mockOrgData);
const organizations$ = new BehaviorSubject<Organization[]>([orgWithoutManageUsers]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
});
it("should return false when organization useAutomaticUserConfirmation is false", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
// Create organization without useAutomaticUserConfirmation
const mockOrgData = new OrganizationData({} as any, {} as any);
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = false;
const permissions = new PermissionsApi();
permissions.manageUsers = true;
mockOrgData.permissions = permissions;
const orgWithoutAutoConfirm = new Organization(mockOrgData);
const organizations$ = new BehaviorSubject<Organization[]>([orgWithoutAutoConfirm]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
});
it("should return false when organization is not found", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const organizations$ = new BehaviorSubject<Organization[]>([]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
});
it("should use the correct feature flag", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
await firstValueFrom(canManage$);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm);
});
});
describe("autoConfirmUser", () => {
const mockPublicKey = "mock-public-key-base64";
const mockPublicKeyArray = new Uint8Array([1, 2, 3, 4]);
const mockConfirmRequest = {
key: "encrypted-key",
defaultUserCollectionName: "encrypted-collection",
} as OrganizationUserConfirmRequest;
beforeEach(() => {
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
organizationService.organizations$.mockReturnValue(organizations$);
configService.getFeatureFlag$.mockReturnValue(of(true));
apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any);
jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray);
organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest));
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
});
it("should successfully auto-confirm a user", async () => {
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
expect(apiService.getUserPublicKey).toHaveBeenCalledWith(mockUserId);
expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith(
mockOrganization,
mockPublicKeyArray,
);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganizationId,
mockConfirmingUserId,
mockConfirmRequest,
);
});
it("should not confirm user when canManageAutoConfirm returns false", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
await expect(
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
).rejects.toThrow("Cannot automatically confirm user (insufficient permissions)");
expect(apiService.getUserPublicKey).not.toHaveBeenCalled();
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should build confirm request with organization and public key", async () => {
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith(
mockOrganization,
mockPublicKeyArray,
);
});
it("should call API with correct parameters", async () => {
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id,
mockConfirmingUserId,
mockConfirmRequest,
);
});
it("should handle API errors gracefully", async () => {
const apiError = new Error("API Error");
apiService.getUserPublicKey.mockRejectedValue(apiError);
await expect(
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
).rejects.toThrow("API Error");
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should handle buildConfirmRequest errors gracefully", async () => {
const buildError = new Error("Build Error");
organizationUserService.buildConfirmRequest.mockReturnValue(throwError(() => buildError));
await expect(
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
).rejects.toThrow("Build Error");
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,90 @@
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { StateProvider } from "@bitwarden/state";
import { UserId } from "@bitwarden/user-core";
import {
DefaultOrganizationUserService,
OrganizationUserApiService,
} from "../../organization-user";
import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction";
import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model";
export class DefaultAutomaticUserConfirmationService implements AutomaticUserConfirmationService {
constructor(
private configService: ConfigService,
private apiService: ApiService,
private organizationUserService: DefaultOrganizationUserService,
private stateProvider: StateProvider,
private organizationService: InternalOrganizationServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
private autoConfirmState(userId: UserId) {
return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE);
}
configuration$(userId: UserId): Observable<AutoConfirmState> {
return this.autoConfirmState(userId).state$.pipe(
map((records) => records?.[userId] ?? new AutoConfirmState()),
);
}
async upsert(userId: UserId, config: AutoConfirmState): Promise<void> {
await this.autoConfirmState(userId).update((records) => {
return {
...records,
[userId]: config,
};
});
}
canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable<boolean> {
return combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
]).pipe(
map(
([enabled, organization]) =>
(enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ??
false,
),
);
}
async autoConfirmUser(
userId: UserId,
confirmingUserId: UserId,
organization: Organization,
): Promise<void> {
await firstValueFrom(
this.canManageAutoConfirm$(userId, organization.id).pipe(
map((canManage) => {
if (!canManage) {
throw new Error("Cannot automatically confirm user (insufficient permissions)");
}
return canManage;
}),
switchMap(() => this.apiService.getUserPublicKey(userId)),
map((publicKeyResponse) => Utils.fromB64ToArray(publicKeyResponse.publicKey)),
switchMap((publicKey) =>
this.organizationUserService.buildConfirmRequest(organization, publicKey),
),
switchMap((request) =>
this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
confirmingUserId,
request,
),
),
),
);
}
}

View File

@@ -0,0 +1 @@
export * from "./default-auto-confirm.service";

View File

@@ -1,2 +1,3 @@
export * from "./organization-user";
export * from "./auto-confirm";
export * from "./collections";
export * from "./organization-user";

View File

@@ -1 +1,2 @@
export * from "./organization-user-api.service";
export * from "./organization-user.service";

View File

@@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService {
request: OrganizationUserConfirmRequest,
): Promise<void>;
/**
* Admin api for automatically confirming an organization user that
* has accepted their invitation
* @param organizationId - Identifier for the organization to confirm
* @param id - Organization user identifier
* @param request - Request details for confirming the user
*/
abstract postOrganizationUserAutoConfirm(
organizationId: string,
id: string,
request: OrganizationUserConfirmRequest,
): Promise<void>;
/**
* Retrieve a list of the specified users' public keys
* @param organizationId - Identifier for the organization to accept

View File

@@ -0,0 +1,45 @@
import { Observable } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
export abstract class OrganizationUserService {
/**
* Builds a confirmation request for an organization user.
* @param organization - The organization the user belongs to
* @param publicKey - The user's public key
* @returns An observable that emits the confirmation request
*/
abstract buildConfirmRequest(
organization: Organization,
publicKey: Uint8Array,
): Observable<OrganizationUserConfirmRequest>;
/**
* Confirms a user in an organization.
* @param organization - The organization the user belongs to
* @param userId - The ID of the user to confirm
* @param publicKey - The user's public key
* @returns An observable that completes when the user is confirmed
*/
abstract confirmUser(
organization: Organization,
userId: string,
publicKey: Uint8Array,
): Observable<void>;
/**
* Confirms multiple users in an organization.
* @param organization - The organization the users belong to
* @param userIdsWithKeys - Array of user IDs with their encrypted keys
* @returns An observable that emits the bulk confirmation response
*/
abstract bulkConfirmUsers(
organization: Organization,
userIdsWithKeys: { id: string; key: string }[],
): Observable<ListResponse<OrganizationUserBulkResponse>>;
}

View File

@@ -194,6 +194,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
);
}
postOrganizationUserAutoConfirm(
organizationId: string,
id: string,
request: OrganizationUserConfirmRequest,
): Promise<void> {
return this.apiService.send(
"POST",
"/organizations/" + organizationId + "/users/" + id + "/auto-confirm",
request,
true,
false,
);
}
async postOrganizationUsersPublicKey(
organizationId: string,
ids: string[],

View File

@@ -19,12 +19,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { DefaultOrganizationUserService } from "./default-organization-user.service";
import { OrganizationUserService } from "./organization-user.service";
describe("OrganizationUserService", () => {
let service: OrganizationUserService;
describe("DefaultOrganizationUserService", () => {
let service: DefaultOrganizationUserService;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
@@ -34,9 +32,7 @@ describe("OrganizationUserService", () => {
const mockOrganization = new Organization();
mockOrganization.id = "org-123" as OrganizationId;
const mockOrganizationUser = new OrganizationUserView();
mockOrganizationUser.id = "user-123";
const mockUserId = "user-123";
const mockPublicKey = new Uint8Array(64) as CsprngArray;
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
@@ -77,7 +73,7 @@ describe("OrganizationUserService", () => {
TestBed.configureTestingModule({
providers: [
OrganizationUserService,
DefaultOrganizationUserService,
{ provide: KeyService, useValue: keyService },
{ provide: EncryptService, useValue: encryptService },
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
@@ -86,7 +82,13 @@ describe("OrganizationUserService", () => {
],
});
service = TestBed.inject(OrganizationUserService);
service = new DefaultOrganizationUserService(
keyService,
encryptService,
organizationUserApiService,
accountService,
i18nService,
);
});
describe("confirmUser", () => {
@@ -97,7 +99,7 @@ describe("OrganizationUserService", () => {
});
it("should confirm a user successfully", (done) => {
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({
service.confirmUser(mockOrganization, mockUserId, mockPublicKey).subscribe({
next: () => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
@@ -112,7 +114,7 @@ describe("OrganizationUserService", () => {
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id,
mockOrganizationUser.id,
mockUserId,
{
key: mockEncryptedKey.encryptedString,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,

View File

@@ -1,4 +1,3 @@
import { Injectable } from "@angular/core";
import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
import {
@@ -6,6 +5,7 @@ import {
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,12 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@Injectable({
providedIn: "root",
})
export class OrganizationUserService {
export class DefaultOrganizationUserService implements OrganizationUserService {
constructor(
protected keyService: KeyService,
private encryptService: EncryptService,
@@ -39,11 +34,10 @@ export class OrganizationUserService {
);
}
confirmUser(
buildConfirmRequest(
organization: Organization,
user: OrganizationUserView,
publicKey: Uint8Array,
): Observable<void> {
): Observable<OrganizationUserConfirmRequest> {
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
const encryptedKey$ = this.orgKey$(organization).pipe(
@@ -51,18 +45,22 @@ export class OrganizationUserService {
);
return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe(
switchMap(([key, collectionName]) => {
const request: OrganizationUserConfirmRequest = {
key: key.encryptedString,
defaultUserCollectionName: collectionName.encryptedString,
};
map(([key, collectionName]) => ({
key: key.encryptedString,
defaultUserCollectionName: collectionName.encryptedString,
})),
);
}
return this.organizationUserApiService.postOrganizationUserConfirm(
confirmUser(organization: Organization, userId: string, publicKey: Uint8Array): Observable<void> {
return this.buildConfirmRequest(organization, publicKey).pipe(
switchMap((request) =>
this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
userId,
request,
);
}),
),
),
);
}

View File

@@ -1 +1,2 @@
export * from "./default-organization-user-api.service";
export * from "./default-organization-user.service";

View File

@@ -1,5 +1,6 @@
import { CurrencyPipe } from "@angular/common";
import { Component, computed, input, signal } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { TypographyModule, IconButtonModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -71,6 +72,11 @@ export class CartSummaryComponent {
*/
readonly total = computed<number>(() => this.getTotalCost());
/**
* Observable of computed total value
*/
readonly total$ = toObservable(this.total);
/**
* Toggles the expanded/collapsed state of the cart items
*/