1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-29 07:43:28 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Amy Galles
2026-01-27 10:25:14 -08:00
committed by GitHub
5 changed files with 315 additions and 55 deletions

View File

@@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
Subject,
takeUntil,
@@ -70,6 +71,7 @@ import {
CipherFormModule,
CipherViewComponent,
CollectionAssignmentResult,
createFilterFunction,
DecryptionFailureDialogComponent,
DefaultChangeLoginPasswordService,
DefaultCipherFormConfigService,
@@ -79,6 +81,7 @@ import {
VaultFilter,
VaultFilterServiceAbstraction as VaultFilterService,
RoutedVaultFilterBridgeService,
RoutedVaultFilterService,
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
@@ -216,6 +219,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
private policyService: PolicyService,
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
private routedVaultFilterService: RoutedVaultFilterService,
private vaultFilterService: VaultFilterService,
private vaultItemTransferService: VaultItemsTransferService,
) {}
@@ -234,9 +238,16 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
});
// Subscribe to filter changes from router params via the bridge service
this.routedVaultFilterBridgeService.activeFilter$
// Use combineLatest to react to changes in both the filter and archive flag
combineLatest([
this.routedVaultFilterBridgeService.activeFilter$,
this.routedVaultFilterService.filter$,
this.cipherArchiveService.hasArchiveFlagEnabled$,
])
.pipe(
switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))),
switchMap(([vaultFilter, routedFilter, archiveEnabled]) =>
from(this.applyVaultFilter(vaultFilter, routedFilter, archiveEnabled)),
),
takeUntil(this.componentIsDestroyed$),
)
.subscribe();
@@ -789,48 +800,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
await this.go().catch(() => {});
}
/**
* Wraps a filter function to handle CipherListView objects.
* CipherListView has a different type structure where type can be a string or object.
* This wrapper converts it to CipherView-compatible structure before filtering.
*/
private wrapFilterForCipherListView(
filterFn: (cipher: CipherView) => boolean,
): (cipher: CipherViewLike) => boolean {
return (cipher: CipherViewLike) => {
// For CipherListView, create a proxy object with the correct type property
if (CipherViewLikeUtils.isCipherListView(cipher)) {
const proxyCipher = {
...cipher,
type: CipherViewLikeUtils.getType(cipher),
// Normalize undefined organizationId to null for filter compatibility
organizationId: cipher.organizationId ?? null,
// Normalize empty string folderId to null for filter compatibility
folderId: cipher.folderId ? cipher.folderId : null,
// Explicitly include isDeleted and isArchived since they might be getters
isDeleted: CipherViewLikeUtils.isDeleted(cipher),
isArchived: CipherViewLikeUtils.isArchived(cipher),
};
return filterFn(proxyCipher as any);
}
return filterFn(cipher);
};
}
async applyVaultFilter(vaultFilter: VaultFilter) {
async applyVaultFilter(
vaultFilter: VaultFilter,
routedFilter: Parameters<typeof createFilterFunction>[0],
archiveEnabled: boolean,
) {
this.searchBarService.setPlaceholderText(
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
);
this.activeFilter = vaultFilter;
const originalFilterFn = this.activeFilter.buildFilter();
const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn);
const filterFn = createFilterFunction(routedFilter, archiveEnabled);
await this.vaultItemsComponent?.reload(
wrappedFilterFn,
vaultFilter.isDeleted,
vaultFilter.isArchived,
);
await this.vaultItemsComponent?.reload(filterFn, vaultFilter.isDeleted, vaultFilter.isArchived);
}
private getAvailableCollections(cipher: CipherView): CollectionView[] {

View File

@@ -0,0 +1,270 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import {
AutoConfirmPolicyDialogComponent,
AutoConfirmPolicyDialogData,
} from "./auto-confirm-edit-policy-dialog.component";
describe("AutoConfirmPolicyDialogComponent", () => {
let component: AutoConfirmPolicyDialogComponent;
let fixture: ComponentFixture<AutoConfirmPolicyDialogComponent>;
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
let mockAccountService: FakeAccountService;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockRouter: MockProxy<Router>;
let mockAutoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let mockDialogRef: MockProxy<DialogRef>;
let mockToastService: MockProxy<ToastService>;
let mockI18nService: MockProxy<I18nService>;
let mockKeyService: MockProxy<KeyService>;
const mockUserId = newGuid() as UserId;
const mockOrgId = newGuid() as OrganizationId;
const mockDialogData: AutoConfirmPolicyDialogData = {
organizationId: mockOrgId,
policy: {
name: "autoConfirm",
description: "Auto Confirm Policy",
type: PolicyType.AutoConfirm,
component: {} as any,
showDescription: true,
display$: () => of(true),
},
firstTimeDialog: false,
};
const mockOrg = {
id: mockOrgId,
name: "Test Organization",
enabled: true,
isAdmin: true,
canManagePolicies: true,
} as Organization;
beforeEach(async () => {
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
mockAccountService = mockAccountServiceWith(mockUserId);
mockOrganizationService = mock<OrganizationService>();
mockPolicyService = mock<PolicyService>();
mockRouter = mock<Router>();
mockAutoConfirmService = mock<AutomaticUserConfirmationService>();
mockDialogRef = mock<DialogRef>();
mockToastService = mock<ToastService>();
mockI18nService = mock<I18nService>();
mockKeyService = mock<KeyService>();
mockPolicyService.policies$.mockReturnValue(of([]));
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
await TestBed.configureTestingModule({
imports: [AutoConfirmPolicyDialogComponent],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: mockDialogData },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: ToastService, useValue: mockToastService },
{ provide: KeyService, useValue: mockKeyService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: Router, useValue: mockRouter },
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(AutoConfirmPolicyDialogComponent, {
set: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent);
component = fixture.componentInstance;
});
afterEach(() => {
jest.resetAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("handleSubmit", () => {
beforeEach(() => {
// Mock the policyComponent
component.policyComponent = {
buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }),
enabled: { value: true },
setSingleOrgEnabled: jest.fn(),
} as any;
mockAutoConfirmService.configuration$.mockReturnValue(
of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }),
);
mockAutoConfirmService.upsert.mockResolvedValue(undefined);
mockI18nService.t.mockReturnValue("Policy updated");
});
it("should enable SingleOrg policy when it was not already enabled", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
// Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled)
await component["handleSubmit"](false);
// First call should be SingleOrg enable
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
1,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
});
it("should not enable SingleOrg policy when it was already enabled", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
// Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled)
await component["handleSubmit"](true);
// Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg)
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
});
it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => {
const autoConfirmError = new Error("AutoConfirm failed");
// First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds
mockPolicyApiService.putPolicyVNext
.mockResolvedValueOnce({} as any) // SingleOrg enable
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
.mockResolvedValueOnce({} as any); // SingleOrg rollback
await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed");
// Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
1,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
2,
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
3,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: false, data: null } },
);
});
it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => {
const autoConfirmError = new Error("AutoConfirm failed");
// AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls)
mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError);
await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed");
// Verify only AutoConfirm was called (no SingleOrg enable/rollback)
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
});
it("should keep both policies enabled when both submissions succeed", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
await component["handleSubmit"](false);
// Verify two calls: SingleOrg enable and AutoConfirm enable
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
1,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
2,
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
});
it("should re-throw the error after rollback", async () => {
const autoConfirmError = new Error("Network error");
mockPolicyApiService.putPolicyVNext
.mockResolvedValueOnce({} as any) // SingleOrg enable
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
.mockResolvedValueOnce({} as any); // SingleOrg rollback
await expect(component["handleSubmit"](false)).rejects.toThrow("Network error");
});
});
describe("setSingleOrgPolicy", () => {
it("should call putPolicyVNext with enabled: true when enabling", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
await component["setSingleOrgPolicy"](true);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
});
it("should call putPolicyVNext with enabled: false when disabling", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
await component["setSingleOrgPolicy"](false);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: false, data: null } },
);
});
});
});

View File

@@ -181,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent
}
private async handleSubmit(singleOrgEnabled: boolean) {
if (!singleOrgEnabled) {
await this.submitSingleOrg();
const enabledSingleOrgDuringAction = !singleOrgEnabled;
if (enabledSingleOrgDuringAction) {
await this.setSingleOrgPolicy(true);
}
try {
await this.submitAutoConfirm();
} catch (error) {
// Roll back SingleOrg if we enabled it during this action
if (enabledSingleOrgDuringAction) {
await this.setSingleOrgPolicy(false);
}
throw error;
}
await this.submitAutoConfirm();
}
/**
@@ -198,11 +209,9 @@ export class AutoConfirmPolicyDialogComponent
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
autoConfirmRequest,
);
await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, {
policy: autoConfirmRequest,
});
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
@@ -225,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent
}
}
private async submitSingleOrg(): Promise<void> {
private async setSingleOrgPolicy(enabled: boolean): Promise<void> {
const singleOrgRequest: PolicyRequest = {
enabled: true,
enabled,
data: null,
};
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
);
await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, {
policy: singleOrgRequest,
});
}
private async openBrowserExtension() {

View File

@@ -319,7 +319,7 @@ module.exports.buildConfig = function buildConfig(params) {
https://*.paypal.com
https://www.paypalobjects.com
https://q.stripe.com
https://haveibeenpwned.com
https://logos.haveibeenpwned.com
;media-src
'self'
https://assets.bitwarden.com

View File

@@ -14,6 +14,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
@@ -267,7 +268,7 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
try {
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
const hostname = environment.getHostname();
const hostname = Utils.getHost(environment.getWebVaultUrl());
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
if (!hostname) {