1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-02 03:21:19 +00:00

Merge branch 'main' into km/pm-27297

This commit is contained in:
Thomas Avery
2026-01-27 16:12:37 -06:00
committed by GitHub
98 changed files with 3757 additions and 1208 deletions

View File

@@ -514,7 +514,7 @@ export class vNextMembersComponent {
if (result.error != null) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t(result.error),
message: result.error,
});
this.logService.error(result.error);
return;

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

@@ -63,7 +63,7 @@
</bit-select>
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'access' | i18n }}">
<bit-tab [label]="accessTabLabel">
<div class="tw-mb-3">
<ng-container *ngIf="dialogReadonly">
<span>{{ "readOnlyCollectionAccess" | i18n }}</span>

View File

@@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
return this.params.readonly === true;
}
protected get accessTabLabel(): string {
return this.dialogReadonly
? this.i18nService.t("viewAccess")
: this.i18nService.t("editAccess");
}
protected async cancel() {
this.close(CollectionDialogAction.Canceled);
}

View File

@@ -57,12 +57,8 @@
<ng-container *ngIf="subscription">
<ng-container *ngIf="enableDiscountDisplay$ | async as enableDiscount; else noDiscount">
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
{{
(sub.subscription.periodEndDate | date: "MMM d, y") +
", " +
(discountedSubscriptionAmount | currency: "$")
}}
<span [attr.aria-label]="'nextChargeDate' | i18n">
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
</span>
<billing-discount-badge
[discount]="getDiscount(sub?.customerDiscount)"
@@ -71,12 +67,8 @@
</ng-container>
<ng-template #noDiscount>
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
{{
(sub.subscription.periodEndDate | date: "MMM d, y") +
", " +
(subscriptionAmount | currency: "$")
}}
<span [attr.aria-label]="'nextChargeDate' | i18n">
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
</span>
</div>
</ng-template>

View File

@@ -722,6 +722,8 @@ export class EventService {
return ["bwi-browser", this.i18nService.t("webVault") + " - Edge"];
case DeviceType.IEBrowser:
return ["bwi-browser", this.i18nService.t("webVault") + " - IE"];
case DeviceType.DuckDuckGoBrowser:
return ["bwi-browser", this.i18nService.t("webVault") + " - DuckDuckGo"];
case DeviceType.Server:
return ["bwi-user-monitor", this.i18nService.t("server")];
case DeviceType.WindowsCLI:

View File

@@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
CenterPositionStrategy,
@@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent {
}
private async deleteCiphersAdmin(ciphers: string[]): Promise<any> {
const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (this.permanent) {
return await this.apiService.deleteManyCiphersAdmin(deleteRequest);
await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id);
} else {
return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest);
await this.cipherService.softDeleteManyWithServer(
ciphers,
userId,
true,
this.organization.id,
);
}
}

View File

@@ -3281,6 +3281,9 @@
"nextChargeHeader": {
"message": "Next Charge"
},
"nextChargeDate": {
"message": "Next charge date"
},
"plan": {
"message": "Plan"
},
@@ -6928,8 +6931,8 @@
"activateAutofill": {
"message": "Activate auto-fill"
},
"activateAutofillPolicyDesc": {
"message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members."
"activateAutofillPolicyDescription": {
"message": "Activate the autofill on page load setting on the browser extension for all existing and new members."
},
"experimentalFeature": {
"message": "Compromised or untrusted websites can exploit auto-fill on page load."
@@ -11366,6 +11369,18 @@
"automaticDomainClaimProcess": {
"message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain cant be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed."
},
"automaticDomainClaimProcess1": {
"message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days."
},
"automaticDomainClaimProcess2": {
"message": "Once claimed, existing members with claimed domains will be emailed about the "
},
"accountOwnershipChange": {
"message": "account ownership change"
},
"automaticDomainClaimProcessEnd": {
"message": "."
},
"domainNotClaimed": {
"message": "$DOMAIN$ not claimed. Check your DNS records.",
"placeholders": {
@@ -11378,8 +11393,8 @@
"domainStatusClaimed": {
"message": "Claimed"
},
"domainStatusUnderVerification": {
"message": "Under verification"
"domainStatusPending": {
"message": "Pending"
},
"claimedDomainsDescription": {
"message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts."

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