1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-12-05 09:14:28 +01:00
This commit is contained in:
Brandon Treston 2025-12-04 18:38:57 -06:00 committed by GitHub
commit eda110e69a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1369 additions and 171 deletions

1
.github/CODEOWNERS vendored
View File

@ -73,6 +73,7 @@ bitwarden_license/bit-cli/src/admin-console @bitwarden/team-admin-console-dev
libs/angular/src/admin-console @bitwarden/team-admin-console-dev
libs/common/src/admin-console @bitwarden/team-admin-console-dev
libs/admin-console @bitwarden/team-admin-console-dev
libs/auto-confirm @bitwarden/team-admin-console-dev
## Billing team files ##
apps/browser/src/billing @bitwarden/team-billing-dev

View File

@ -4793,6 +4793,24 @@
"adminConsole": {
"message": "Admin Console"
},
"admin" :{
"message": "Admin"
},
"automaticUserConfirmation": {
"message": "Automatic user confirmation"
},
"automaticUserConfirmationHint": {
"message": "Automatically confirm pending users while this device is unlocked"
},
"autoConfirmOnboardingCallout":{
"message": "Save time with automatic user confirmation"
},
"autoConfirmWarning": {
"message": "This could impact your organizations data security. "
},
"autoConfirmWarningLink": {
"message": "Learn about the risks"
},
"accountSecurity": {
"message": "Account security"
},

View File

@ -7,6 +7,7 @@ import { firstValueFrom, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { LockService } from "@bitwarden/auth/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@ -95,6 +96,10 @@ describe("AccountSecurityComponent", () => {
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
{
provide: AutomaticUserConfirmationService,
useValue: mock<AutomaticUserConfirmationService>(),
},
{ provide: ConfigService, useValue: configService },
],
})

View File

@ -42,6 +42,7 @@ import {
TwoFactorAuthComponent,
TwoFactorAuthGuard,
} from "@bitwarden/auth/angular";
import { canAccessAutoConfirmSettings } from "@bitwarden/auto-confirm";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
@ -86,6 +87,7 @@ import {
} from "../vault/popup/guards/at-risk-passwords.guard";
import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard";
import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard";
import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component";
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
import { ArchiveComponent } from "../vault/popup/settings/archive.component";
import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component";
@ -315,6 +317,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "admin",
component: AdminSettingsComponent,
canActivate: [authGuard, canAccessAutoConfirmSettings],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "clone-cipher",
component: AddEditV2Component,

View File

@ -3,7 +3,11 @@
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
import { merge, of, Subject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
OrganizationUserApiService,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
@ -38,9 +42,14 @@ import {
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
AccountService,
@ -723,6 +732,19 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiService,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: BrowserSessionTimeoutSettingsComponentService,

View File

@ -84,6 +84,24 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
@if (showAdminSettingsLink$ | async) {
<bit-item>
<a bit-item-content routerLink="/admin">
<i slot="start" class="bwi bwi-business" aria-hidden="true"></i>
<div class="tw-flex tw-items-center tw-justify-center">
<p class="tw-pr-2">{{ "admin" | i18n }}</p>
@if (showAdminBadge$ | async) {
<span bitBadge variant="notification" [attr.aria-label]="'nudgeBadgeAria' | i18n"
>1</span
>
}
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
}
<bit-item>
<a bit-item-content routerLink="/about">
<i slot="start" class="bwi bwi-info-circle" aria-hidden="true"></i>

View File

@ -6,6 +6,7 @@ import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { AutofillBrowserSettingsService } from "@bitwarden/browser/autofill/services/autofill-browser-settings.service";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@ -42,6 +43,9 @@ describe("SettingsV2Component", () => {
defaultBrowserAutofillDisabled$: Subject<boolean>;
isBrowserAutofillSettingOverridden: jest.Mock<Promise<boolean>>;
};
let mockAutoConfirmService: {
canManageAutoConfirm$: jest.Mock;
};
let dialogService: MockProxy<DialogService>;
let openSpy: jest.SpyInstance;
@ -66,6 +70,10 @@ describe("SettingsV2Component", () => {
isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false),
};
mockAutoConfirmService = {
canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)),
};
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome");
const cfg = TestBed.configureTestingModule({
@ -75,6 +83,7 @@ describe("SettingsV2Component", () => {
{ provide: BillingAccountProfileStateService, useValue: mockBillingState },
{ provide: NudgesService, useValue: mockNudges },
{ provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings },
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
{ provide: DialogService, useValue: dialogService },
{ provide: I18nService, useValue: { t: jest.fn((key: string) => key) } },
{ provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() },

View File

@ -16,7 +16,9 @@ import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/compon
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { UserId } from "@bitwarden/common/types/guid";
import {
@ -82,6 +84,12 @@ export class SettingsV2Component {
),
);
showAdminBadge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) =>
this.nudgesService.showNudgeBadge$(NudgeType.AutoConfirmNudge, account.id),
),
);
showAutofillBadge$: Observable<boolean> = combineLatest([
this.autofillBrowserSettingsService.defaultBrowserAutofillDisabled$,
this.authenticatedAccount$,
@ -95,10 +103,16 @@ export class SettingsV2Component {
),
);
showAdminSettingsLink$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.autoConfirmService.canManageAutoConfirm$(userId)),
);
constructor(
private readonly nudgesService: NudgesService,
private readonly accountService: AccountService,
private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService,
private readonly autoConfirmService: AutomaticUserConfirmationService,
private readonly accountProfileStateService: BillingAccountProfileStateService,
private readonly dialogService: DialogService,
) {}

View File

@ -0,0 +1,41 @@
<popup-page [loading]="formLoading">
<popup-header slot="header" [pageTitle]="'admin' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div class="tw-px-1 tw-pt-1">
@if (showAutoConfirmSpotlight$ | async) {
<bit-spotlight [persistent]="true">
<div class="tw-flex tw-flex-row tw-items-center tw-justify-between">
<span class="tw-text-sm">
{{ "autoConfirmOnboardingCallout" | i18n }}
</span>
<button
type="button"
bitIconButton="bwi-close"
size="small"
(click)="dismissSpotlight()"
class="tw-ml-1 tw-mt-[2px]"
[label]="'close' | i18n"
></button>
</div>
</bit-spotlight>
}
<form [formGroup]="adminForm">
<bit-card>
<bit-switch formControlName="autoConfirm">
<bit-label>
<span class="tw-text-sm">
{{ "automaticUserConfirmation" | i18n }}
</span>
</bit-label>
<bit-hint class="tw-max-w-[18rem]">{{ "automaticUserConfirmationHint" | i18n }}</bit-hint>
</bit-switch>
</bit-card>
</form>
</div>
</popup-page>

View File

@ -0,0 +1,199 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AutoConfirmState, AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { AdminSettingsComponent } from "./admin-settings.component";
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupHeaderComponent {
readonly pageTitle = input<string>();
readonly backAction = input<() => void>();
}
@Component({
selector: "popup-page",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupPageComponent {
readonly loading = input<boolean>();
}
@Component({
selector: "app-pop-out",
template: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopOutComponent {
readonly show = input<boolean>(true);
}
describe("AdminSettingsComponent", () => {
let component: AdminSettingsComponent;
let fixture: ComponentFixture<AdminSettingsComponent>;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let nudgesService: MockProxy<NudgesService>;
let mockDialogService: MockProxy<DialogService>;
const userId = "test-user-id" as UserId;
const mockAutoConfirmState: AutoConfirmState = {
enabled: false,
showSetupDialog: true,
showBrowserNotification: false,
};
beforeEach(async () => {
autoConfirmService = mock<AutomaticUserConfirmationService>();
nudgesService = mock<NudgesService>();
mockDialogService = mock<DialogService>();
autoConfirmService.configuration$.mockReturnValue(of(mockAutoConfirmState));
autoConfirmService.upsert.mockResolvedValue(undefined);
nudgesService.showNudgeSpotlight$.mockReturnValue(of(false));
await TestBed.configureTestingModule({
imports: [AdminSettingsComponent],
providers: [
provideNoopAnimations(),
{ provide: AccountService, useValue: mockAccountServiceWith(userId) },
{ provide: AutomaticUserConfirmationService, useValue: autoConfirmService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: NudgesService, useValue: nudgesService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
})
.overrideComponent(AdminSettingsComponent, {
remove: {
imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent],
},
add: {
imports: [MockPopupHeaderComponent, MockPopupPageComponent, MockPopOutComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(AdminSettingsComponent);
component = fixture.componentInstance;
});
describe("initialization", () => {
it("should populate form with current auto-confirm state", async () => {
const mockState: AutoConfirmState = {
enabled: true,
showSetupDialog: false,
showBrowserNotification: true,
};
autoConfirmService.configuration$.mockReturnValue(of(mockState));
await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
expect(component["adminForm"].value).toEqual({
autoConfirm: true,
});
});
it("should populate form with disabled auto-confirm state", async () => {
await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
expect(component["adminForm"].value).toEqual({
autoConfirm: false,
});
});
});
describe("spotlight", () => {
beforeEach(async () => {
await component.ngOnInit();
fixture.detectChanges();
});
it("should expose showAutoConfirmSpotlight$ observable", (done) => {
nudgesService.showNudgeSpotlight$.mockReturnValue(of(true));
const newFixture = TestBed.createComponent(AdminSettingsComponent);
const newComponent = newFixture.componentInstance;
newComponent["showAutoConfirmSpotlight$"].subscribe((show) => {
expect(show).toBe(true);
expect(nudgesService.showNudgeSpotlight$).toHaveBeenCalledWith(
NudgeType.AutoConfirmNudge,
userId,
);
done();
});
});
it("should dismiss spotlight and update state", async () => {
autoConfirmService.upsert.mockResolvedValue();
await component.dismissSpotlight();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, {
...mockAutoConfirmState,
showBrowserNotification: false,
});
});
it("should use current userId when dismissing spotlight", async () => {
autoConfirmService.upsert.mockResolvedValue();
await component.dismissSpotlight();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, expect.any(Object));
});
it("should preserve existing state when dismissing spotlight", async () => {
const customState: AutoConfirmState = {
enabled: true,
showSetupDialog: false,
showBrowserNotification: true,
};
autoConfirmService.configuration$.mockReturnValue(of(customState));
autoConfirmService.upsert.mockResolvedValue();
await component.dismissSpotlight();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, {
...customState,
showBrowserNotification: false,
});
});
});
describe("form validation", () => {
beforeEach(async () => {
await component.ngOnInit();
fixture.detectChanges();
});
it("should have a valid form", () => {
expect(component["adminForm"].valid).toBe(true);
});
it("should have autoConfirm control", () => {
expect(component["adminForm"].controls.autoConfirm).toBeDefined();
});
});
});

View File

@ -0,0 +1,109 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom, lastValueFrom, Observable, switchMap, withLatestFrom } from "rxjs";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import {
AutoConfirmWarningDialogComponent,
AutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "@bitwarden/browser/platform/popup/layout/popup-page.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
BitIconButtonComponent,
CardComponent,
DialogService,
FormFieldModule,
SwitchComponent,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { UserId } from "@bitwarden/user-core";
@Component({
templateUrl: "./admin-settings.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
FormFieldModule,
ReactiveFormsModule,
SwitchComponent,
CardComponent,
SpotlightComponent,
BitIconButtonComponent,
I18nPipe,
],
})
export class AdminSettingsComponent implements OnInit {
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
protected formLoading = true;
protected adminForm = this.formBuilder.group({
autoConfirm: false,
});
protected showAutoConfirmSpotlight$: Observable<boolean> = this.userId$.pipe(
switchMap((userId) =>
this.nudgesService.showNudgeSpotlight$(NudgeType.AutoConfirmNudge, userId),
),
);
constructor(
private formBuilder: FormBuilder,
private accountService: AccountService,
private autoConfirmService: AutomaticUserConfirmationService,
private destroyRef: DestroyRef,
private dialogService: DialogService,
private nudgesService: NudgesService,
) {}
async ngOnInit() {
this.formLoading = false;
const userId = await firstValueFrom(this.userId$);
const autoConfirmEnabled = (
await firstValueFrom(this.autoConfirmService.configuration$(userId))
).enabled;
this.adminForm.setValue({ autoConfirm: autoConfirmEnabled });
this.adminForm.controls.autoConfirm.valueChanges
.pipe(
switchMap(async (newValue) => {
if (newValue) {
const ref = AutoConfirmWarningDialogComponent.open(this.dialogService);
const result = await lastValueFrom(ref.closed);
if (result) {
return newValue;
}
}
this.adminForm.setValue({ autoConfirm: false }, { emitEvent: false });
return false;
}),
withLatestFrom(this.autoConfirmService.configuration$(userId)),
switchMap(([newValue, existingState]) =>
this.autoConfirmService.upsert(userId, {
...existingState,
enabled: newValue,
showBrowserNotification: false,
}),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
async dismissSpotlight() {
const userId = await firstValueFrom(this.userId$);
const state = await firstValueFrom(this.autoConfirmService.configuration$(userId));
await this.autoConfirmService.upsert(userId, { ...state, showBrowserNotification: false });
}
}

View File

@ -22,7 +22,7 @@ import {
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common";
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";

View File

@ -188,7 +188,7 @@ describe("PoliciesComponent", () => {
});
describe("orgPolicies$", () => {
it("should fetch policies from API for current organization", async () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: newGuid(),
@ -206,39 +206,63 @@ describe("PoliciesComponent", () => {
},
];
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
beforeEach(async () => {
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual(listResponse.data);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should fetch policies from API for current organization", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies.length).toBe(2);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
});
});
it("should return empty array when API returns no data", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns no data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
it("should return empty array when API returns null data", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
describe("with null data", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns null data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
});
describe("policiesEnabledMap$", () => {
it("should create a map of policy types to their enabled status", async () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: "policy-1",
@ -263,27 +287,43 @@ describe("PoliciesComponent", () => {
},
];
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create a map of policy types to their enabled status", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
});
});
it("should create empty map when no policies exist", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create empty map when no policies exist", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
});
});
});
@ -292,31 +332,36 @@ describe("PoliciesComponent", () => {
expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId);
});
it("should refresh policies when policyService emits", async () => {
const policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
describe("when policyService emits", () => {
let policiesSubject: BehaviorSubject<any[]>;
let callCount: number;
let callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
beforeEach(async () => {
policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
});
fixture = TestBed.createComponent(PoliciesComponent);
fixture.detectChanges();
});
const newFixture = TestBed.createComponent(PoliciesComponent);
newFixture.detectChanges();
it("should refresh policies when policyService emits", () => {
const initialCallCount = callCount;
const initialCallCount = callCount;
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
expect(callCount).toBeGreaterThan(initialCallCount);
newFixture.destroy();
expect(callCount).toBeGreaterThan(initialCallCount);
});
});
});
describe("handleLaunchEvent", () => {
it("should open policy dialog when policyId is in query params", async () => {
describe("when policyId is in query params", () => {
const mockPolicyId = newGuid();
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
@ -335,54 +380,59 @@ describe("PoliciesComponent", () => {
data: null,
};
queryParamsSubject.next({ policyId: mockPolicyId });
let dialogOpenSpy: jest.SpyInstance;
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
beforeEach(async () => {
queryParamsSubject.next({ policyId: mockPolicyId });
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
),
),
),
);
);
const dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.compileComponents();
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
const newFixture = TestBed.createComponent(PoliciesComponent);
newFixture.detectChanges();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
newFixture.destroy();
it("should open policy dialog when policyId is in query params", () => {
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
});
});
it("should not open dialog when policyId is not in query params", async () => {

View File

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, Observable, of, switchMap, first, map } from "rxjs";
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
@ -70,6 +70,7 @@ export class PoliciesComponent {
switchMap(() => this.organizationId$),
switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)),
map((response) => (response.data != null && response.data.length > 0 ? response.data : [])),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected policiesEnabledMap$: Observable<Map<PolicyType, boolean>> = this.orgPolicies$.pipe(

View File

@ -9,8 +9,6 @@ import {
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
OrganizationUserService,
DefaultOrganizationUserService,
} from "@bitwarden/admin-console/common";
@ -46,6 +44,10 @@ import {
InternalUserDecryptionOptionsServiceAbstraction,
LoginEmailService,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@ -370,6 +372,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { Route, RouterModule, Routes } from "@angular/router";
import { organizationPolicyGuard } from "@bitwarden/angular/admin-console/guards";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import { AuthRoute } from "@bitwarden/angular/auth/constants";
import {
@ -56,7 +57,6 @@ import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/gua
import { flagEnabled, Flags } from "../utils/flags";
import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard";
import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";

View File

@ -26,7 +26,6 @@ import {
} from "rxjs/operators";
import {
AutomaticUserConfirmationService,
CollectionData,
CollectionDetailsResponse,
CollectionService,
@ -42,6 +41,7 @@ import {
ItemTypes,
Icon,
} from "@bitwarden/assets/svg";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import {

View File

@ -59,6 +59,7 @@ module.exports = {
"<rootDir>/libs/tools/send/send-ui/jest.config.js",
"<rootDir>/libs/user-core/jest.config.js",
"<rootDir>/libs/vault/jest.config.js",
"<rootDir>/libs/auto-confirm/jest.config.js",
],
// Workaround for a memory leak that crashes tests in CI:

View File

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

View File

@ -0,0 +1 @@
export * from "./org-policy.guard";

View File

@ -0,0 +1,226 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../../libs/common/spec";
import { NUDGE_DISMISSED_DISK_KEY, NudgeType } from "../nudges.service";
import { AutoConfirmNudgeService } from "./auto-confirm-nudge.service";
describe("AutoConfirmNudgeService", () => {
let service: AutoConfirmNudgeService;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let fakeStateProvider: FakeStateProvider;
const userId = "user-id" as UserId;
const mockAutoConfirmState = {
enabled: true,
showSetupDialog: false,
showBrowserNotification: true,
};
beforeEach(() => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
autoConfirmService = mock<AutomaticUserConfirmationService>();
TestBed.configureTestingModule({
providers: [
AutoConfirmNudgeService,
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: AutomaticUserConfirmationService,
useValue: autoConfirmService,
},
],
});
service = TestBed.inject(AutoConfirmNudgeService);
});
describe("nudgeStatus$", () => {
it("should return all dismissed when user cannot manage auto-confirm", async () => {
autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState));
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
});
});
it("should return all dismissed when showBrowserNotification is false", async () => {
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: false,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
});
});
it("should return not dismissed when showBrowserNotification is true and user can manage", async () => {
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: true,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: false,
hasSpotlightDismissed: false,
});
});
it("should return not dismissed when showBrowserNotification is undefined and user can manage", async () => {
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: undefined,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: false,
hasSpotlightDismissed: false,
});
});
it("should return stored nudge status when badge is already dismissed", async () => {
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
[NudgeType.AutoConfirmNudge]: {
hasBadgeDismissed: true,
hasSpotlightDismissed: false,
},
}));
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: true,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: true,
hasSpotlightDismissed: false,
});
});
it("should return stored nudge status when spotlight is already dismissed", async () => {
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
[NudgeType.AutoConfirmNudge]: {
hasBadgeDismissed: false,
hasSpotlightDismissed: true,
},
}));
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: true,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: false,
hasSpotlightDismissed: true,
});
});
it("should return stored nudge status when both badge and spotlight are already dismissed", async () => {
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
[NudgeType.AutoConfirmNudge]: {
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
},
}));
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: true,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
});
});
it("should prioritize user permissions over showBrowserNotification setting", async () => {
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
[NudgeType.AutoConfirmNudge]: {
hasBadgeDismissed: false,
hasSpotlightDismissed: false,
},
}));
autoConfirmService.configuration$.mockReturnValue(
new BehaviorSubject({
...mockAutoConfirmState,
showBrowserNotification: true,
}),
);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
});
});
it("should respect stored dismissal even when user cannot manage auto-confirm", async () => {
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
[NudgeType.AutoConfirmNudge]: {
hasBadgeDismissed: true,
hasSpotlightDismissed: false,
},
}));
autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState));
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false));
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
expect(result).toEqual({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
});
});
});
});

View File

@ -0,0 +1,41 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { UserId } from "@bitwarden/user-core";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeType, NudgeStatus } from "../nudges.service";
@Injectable({ providedIn: "root" })
export class AutoConfirmNudgeService extends DefaultSingleNudgeService {
autoConfirmService = inject(AutomaticUserConfirmationService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.autoConfirmService.configuration$(userId),
this.autoConfirmService.canManageAutoConfirm$(userId),
]).pipe(
map(([nudgeStatus, autoConfirmState, canManageAutoConfirm]) => {
if (!canManageAutoConfirm) {
return {
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
};
}
if (nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed) {
return nudgeStatus;
}
const dismissed = autoConfirmState.showBrowserNotification === false;
return {
hasBadgeDismissed: dismissed,
hasSpotlightDismissed: dismissed,
};
}),
);
}
}

View File

@ -4,3 +4,4 @@ export * from "./empty-vault-nudge.service";
export * from "./vault-settings-import-nudge.service";
export * from "./new-item-nudge.service";
export * from "./new-account-nudge.service";
export * from "./auto-confirm-nudge.service";

View File

@ -23,6 +23,7 @@ import {
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { AutoConfirmNudgeService } from "./custom-nudges-services/auto-confirm-nudge.service";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { NudgesService, NudgeType } from "./nudges.service";
@ -35,6 +36,7 @@ describe("Vault Nudges Service", () => {
EmptyVaultNudgeService,
NewAccountNudgeService,
AccountSecurityNudgeService,
AutoConfirmNudgeService,
];
beforeEach(async () => {
@ -73,6 +75,10 @@ describe("Vault Nudges Service", () => {
provide: VaultSettingsImportNudgeService,
useValue: mock<VaultSettingsImportNudgeService>(),
},
{
provide: AutoConfirmNudgeService,
useValue: mock<AutoConfirmNudgeService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),

View File

@ -12,6 +12,7 @@ import {
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
AutoConfirmNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
@ -37,6 +38,7 @@ export const NudgeType = {
NewNoteItemStatus: "new-note-item-status",
NewSshItemStatus: "new-ssh-item-status",
GeneratorNudgeStatus: "generator-nudge-status",
AutoConfirmNudge: "auto-confirm-nudge",
PremiumUpgrade: "premium-upgrade",
} as const;
@ -74,6 +76,7 @@ export class NudgesService {
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
[NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
};
/**
@ -140,6 +143,7 @@ export class NudgesService {
NudgeType.EmptyVaultNudge,
NudgeType.DownloadBitwarden,
NudgeType.AutofillNudge,
NudgeType.AutoConfirmNudge,
];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {

View File

@ -0,0 +1,18 @@
# Automatic User Confirmation
Owned by: admin-console
The automatic user confirmation (auto confirm) feature enables an organization to confirm users to an organization without manual intervention
from any user as long as an administrator's device is unlocked. The feature is enabled via the following:
1. an organization plan feature in the Bitwarden Portal (enabled by an internal team)
2. the automatic user confirmation policy in the Admin Console (enabled by an organization admin)
3. a toggle switch in the extension's admin settings page (enabled on the admin's local device)
Once these three toggles are enabled, auto confirm will be enabled and users will be auto confirmed as long as an admin is logged in. Note that the setting in
the browser extension is not synced across clients, therefore it will not be enabled if the same admin logs into another browser until it is enabled in that
browser. This is an intentional security measure to ensure that the server cannot enable the feature unilaterally.
Once enabled, the AutomaticUserConfirmationService runs in the background on admins' devices and reacts to push notifications from the server containing organization members who need confirmation.
For more information about security goals and the push notification system, see [README in server repo](https://github.com/bitwarden/server/tree/main/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser).

View File

@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@ -0,0 +1,18 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
module.exports = {
...sharedConfig,
displayName: "auto-confirm",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
coverageDirectory: "../../coverage/libs/auto-confirm",
moduleNameMapper: pathsToModuleNameMapper(
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/../../",
},
),
};

View File

@ -0,0 +1,11 @@
{
"name": "@bitwarden/auto-confirm",
"version": "0.0.1",
"description": "auto confirm",
"private": true,
"type": "commonjs",
"main": "index.js",
"types": "index.d.ts",
"license": "GPL-3.0",
"author": "admin-console"
}

View File

@ -0,0 +1,34 @@
{
"name": "auto-confirm",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/auto-confirm/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/auto-confirm",
"main": "libs/auto-confirm/src/index.ts",
"tsConfig": "libs/auto-confirm/tsconfig.lib.json",
"assets": ["libs/auto-confirm/*.md"],
"rootDir": "libs/auto-confirm/src"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/auto-confirm/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/auto-confirm/jest.config.js"
}
}
}
}

View File

@ -1,7 +1,6 @@
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";
@ -24,10 +23,7 @@ export abstract class AutomaticUserConfirmationService {
* @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>;
abstract canManageAutoConfirm$(userId: UserId): 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.

View File

@ -0,0 +1,20 @@
<bit-simple-dialog dialogSize="small">
<span bitDialogTitle>
<strong>{{ "warningCapitalized" | i18n }}</strong>
</span>
<span bitDialogContent>
{{ "autoConfirmWarning" | i18n }}
<a bitLink href="https://bitwarden.com/help/automatic-confirmation/" target="_blank">
{{ "autoConfirmWarningLink" | i18n }}
<i class="bwi bwi-external-link bwi-fw"></i>
</a>
</span>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close(true)">
{{ "turnOn" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="dialogRef.close(false)">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>

View File

@ -0,0 +1,19 @@
import { DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./auto-confirm-warning-dialog.component.html",
imports: [ButtonModule, DialogModule, CommonModule, I18nPipe],
})
export class AutoConfirmWarningDialogComponent {
constructor(public dialogRef: DialogRef<boolean>) {}
static open(dialogService: DialogService) {
return dialogService.open<boolean>(AutoConfirmWarningDialogComponent);
}
}

View File

@ -0,0 +1 @@
export * from "./auto-confirm-warning-dialog.component";

View File

@ -0,0 +1,92 @@
import { TestBed } from "@angular/core/testing";
import { Router, UrlTree } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { AutomaticUserConfirmationService } from "../abstractions";
import { canAccessAutoConfirmSettings } from "./automatic-user-confirmation-settings.guard";
describe("canAccessAutoConfirmSettings", () => {
let accountService: MockProxy<AccountService>;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let toastService: MockProxy<ToastService>;
let i18nService: MockProxy<I18nService>;
let router: MockProxy<Router>;
const mockUserId = newGuid() as UserId;
const mockAccount: Account = {
id: mockUserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
};
let activeAccount$: BehaviorSubject<Account | null>;
const runGuard = () => {
return TestBed.runInInjectionContext(() => {
return canAccessAutoConfirmSettings(null as any, null as any) as Observable<
boolean | UrlTree
>;
});
};
beforeEach(() => {
accountService = mock<AccountService>();
autoConfirmService = mock<AutomaticUserConfirmationService>();
toastService = mock<ToastService>();
i18nService = mock<I18nService>();
router = mock<Router>();
activeAccount$ = new BehaviorSubject<Account | null>(mockAccount);
accountService.activeAccount$ = activeAccount$;
TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: AutomaticUserConfirmationService, useValue: autoConfirmService },
{ provide: ToastService, useValue: toastService },
{ provide: I18nService, useValue: i18nService },
{ provide: Router, useValue: router },
],
});
});
it("should allow access when user has permission", async () => {
autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true));
const result = await firstValueFrom(runGuard());
expect(result).toBe(true);
});
it("should redirect to vault when user lacks permission", async () => {
autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(false));
const mockUrlTree = {} as UrlTree;
router.createUrlTree.mockReturnValue(mockUrlTree);
const result = await firstValueFrom(runGuard());
expect(result).toBe(mockUrlTree);
expect(router.createUrlTree).toHaveBeenCalledWith(["/tabs/vault"]);
});
it("should not emit when active account is null", async () => {
activeAccount$.next(null);
autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true));
let guardEmitted = false;
const subscription = runGuard().subscribe(() => {
guardEmitted = true;
});
expect(guardEmitted).toBe(false);
subscription.unsubscribe();
});
});

View File

@ -0,0 +1,35 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { map, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { ToastService } from "@bitwarden/components";
import { AutomaticUserConfirmationService } from "../abstractions";
export const canAccessAutoConfirmSettings: CanActivateFn = () => {
const accountService = inject(AccountService);
const autoConfirmService = inject(AutomaticUserConfirmationService);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const router = inject(Router);
return accountService.activeAccount$.pipe(
filterOutNullish(),
switchMap((user) => autoConfirmService.canManageAutoConfirm$(user.id)),
map((canManageAutoConfirm) => {
if (!canManageAutoConfirm) {
toastService.showToast({
variant: "error",
title: "",
message: i18nService.t("noPermissionsViewPage"),
});
return router.createUrlTree(["/tabs/vault"]);
}
return true;
}),
);
};

View File

@ -0,0 +1 @@
export * from "./automatic-user-confirmation-settings.guard";

View File

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

View File

@ -1,62 +1,55 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
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";
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
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 { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
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 { newGuid } from "@bitwarden/guid";
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 configService: MockProxy<ConfigService>;
let apiService: MockProxy<ApiService>;
let organizationUserService: MockProxy<DefaultOrganizationUserService>;
let stateProvider: FakeStateProvider;
let organizationService: jest.Mocked<InternalOrganizationServiceAbstraction>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let policyService: MockProxy<PolicyService>;
const mockUserId = Utils.newGuid() as UserId;
const mockConfirmingUserId = Utils.newGuid() as UserId;
const mockOrganizationId = Utils.newGuid() as OrganizationId;
const mockUserId = newGuid() as UserId;
const mockConfirmingUserId = newGuid() as UserId;
const mockOrganizationId = 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;
configService = mock<ConfigService>();
apiService = mock<ApiService>();
organizationUserService = mock<DefaultOrganizationUserService>();
stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId));
organizationService = {
organizations$: jest.fn(),
} as any;
organizationUserApiService = {
postOrganizationUserConfirm: jest.fn(),
} as any;
organizationService = mock<InternalOrganizationServiceAbstraction>();
organizationUserApiService = mock<OrganizationUserApiService>();
policyService = mock<PolicyService>();
TestBed.configureTestingModule({
providers: [
@ -70,6 +63,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
useValue: organizationService,
},
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: PolicyService, useValue: policyService },
],
});
@ -80,9 +74,13 @@ describe("DefaultAutomaticUserConfirmationService", () => {
stateProvider,
organizationService,
organizationUserApiService,
policyService,
);
const mockOrgData = new OrganizationData({} as any, {} as any);
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
isMember: true,
isProviderUser: false,
});
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = true;
@ -180,7 +178,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
});
it("should preserve other user configurations when updating", async () => {
const otherUserId = Utils.newGuid() as UserId;
const otherUserId = newGuid() as UserId;
const otherConfig = new AutoConfirmState();
otherConfig.enabled = true;
@ -209,12 +207,13 @@ describe("DefaultAutomaticUserConfirmationService", () => {
beforeEach(() => {
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
organizationService.organizations$.mockReturnValue(organizations$);
policyService.policyAppliesToUser$.mockReturnValue(of(true));
});
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$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(true);
@ -223,7 +222,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
it("should return false when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
@ -233,7 +232,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
// Create organization without manageUsers permission
const mockOrgData = new OrganizationData({} as any, {} as any);
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
isMember: true,
isProviderUser: false,
});
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = true;
const permissions = new PermissionsApi();
@ -244,7 +246,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
const organizations$ = new BehaviorSubject<Organization[]>([orgWithoutManageUsers]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
@ -254,7 +256,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
// Create organization without useAutomaticUserConfirmation
const mockOrgData = new OrganizationData({} as any, {} as any);
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
isMember: true,
isProviderUser: false,
});
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = false;
const permissions = new PermissionsApi();
@ -265,7 +270,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
const organizations$ = new BehaviorSubject<Organization[]>([orgWithoutAutoConfirm]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
@ -277,7 +282,31 @@ describe("DefaultAutomaticUserConfirmationService", () => {
const organizations$ = new BehaviorSubject<Organization[]>([]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
});
it("should return false when the user is not a member of any organizations", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
// Create organization where user is not a member
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
isMember: false,
isProviderUser: false,
});
mockOrgData.id = mockOrganizationId;
mockOrgData.useAutomaticUserConfirmation = true;
const permissions = new PermissionsApi();
permissions.manageUsers = true;
mockOrgData.permissions = permissions;
const orgWhereNotMember = new Organization(mockOrgData);
const organizations$ = new BehaviorSubject<Organization[]>([orgWhereNotMember]);
organizationService.organizations$.mockReturnValue(organizations$);
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
@ -286,11 +315,58 @@ describe("DefaultAutomaticUserConfirmationService", () => {
it("should use the correct feature flag", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
const canManage$ = service.canManageAutoConfirm$(mockUserId);
await firstValueFrom(canManage$);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm);
});
it("should return false when policy does not apply to user", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policyAppliesToUser$.mockReturnValue(of(false));
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
});
it("should return true when policy applies to user", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policyAppliesToUser$.mockReturnValue(of(true));
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(true);
});
it("should check policy with correct PolicyType and userId", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policyAppliesToUser$.mockReturnValue(of(true));
const canManage$ = service.canManageAutoConfirm$(mockUserId);
await firstValueFrom(canManage$);
expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith(
PolicyType.AutoConfirm,
mockUserId,
);
});
it("should return false when feature flag is enabled but policy does not apply", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policyAppliesToUser$.mockReturnValue(of(false));
const canManage$ = service.canManageAutoConfirm$(mockUserId);
const canManage = await firstValueFrom(canManage$);
expect(canManage).toBe(false);
expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith(
PolicyType.AutoConfirm,
mockUserId,
);
});
});
describe("autoConfirmUser", () => {
@ -305,8 +381,11 @@ describe("DefaultAutomaticUserConfirmationService", () => {
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
organizationService.organizations$.mockReturnValue(organizations$);
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policyAppliesToUser$.mockReturnValue(of(true));
apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any);
apiService.getUserPublicKey.mockResolvedValue({
publicKey: mockPublicKey,
} as UserKeyResponse);
jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray);
organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest));
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);

View File

@ -1,17 +1,20 @@
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.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 { 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 { OrganizationUserApiService, OrganizationUserService } from "../../organization-user";
import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction";
import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model";
@ -23,6 +26,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
private stateProvider: StateProvider,
private organizationService: InternalOrganizationServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
private policyService: PolicyService,
) {}
private autoConfirmState(userId: UserId) {
return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE);
@ -43,15 +47,19 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
});
}
canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable<boolean> {
canManageAutoConfirm$(userId: UserId): Observable<boolean> {
return combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
this.organizationService
.organizations$(userId)
// auto-confirm does not allow the user to be part of any other organization (even if admin or owner)
// so we can assume that the first organization is the relevant one to test.
.pipe(map((organizations) => organizations[0])),
this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
]).pipe(
map(
([enabled, organization]) =>
(enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ??
false,
([enabled, organization, policyEnabled]) =>
enabled && policyEnabled && (organization?.canManageAutoConfirm ?? false),
),
);
}
@ -62,7 +70,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
organization: Organization,
): Promise<void> {
await firstValueFrom(
this.canManageAutoConfirm$(userId, organization.id).pipe(
this.canManageAutoConfirm$(userId).pipe(
map((canManage) => {
if (!canManage) {
throw new Error("Cannot automatically confirm user (insufficient permissions)");

View File

@ -0,0 +1,28 @@
import { webcrypto } from "crypto";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {
return {
display: "none",
appearance: ["-webkit-appearance"],
};
},
});
Object.defineProperty(document, "doctype", {
value: "<!DOCTYPE html>",
});
Object.defineProperty(document.body.style, "transform", {
value: () => {
return {
enumerable: true,
configurable: true,
};
},
});
Object.defineProperty(window, "crypto", {
value: webcrypto,
});

View File

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": ["src/**/*.ts", "src/**/*.js"],
"exclude": ["**/build", "**/dist"]
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

View File

@ -75,8 +75,8 @@ export function canAccessEmergencyAccess(
) {
return combineLatest([
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser)));
}
/**

View File

@ -381,6 +381,13 @@ export class Organization {
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
}
/**
* Do not call this function to perform business logic, consider using the function in @link AutomaticUserConfirmationService instead.
**/
get canManageAutoConfirm() {
return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation;
}
static fromJSON(json: Jsonify<Organization>) {
if (json == null) {
return null;

9
package-lock.json generated
View File

@ -314,6 +314,11 @@
"version": "0.0.0",
"license": "GPL-3.0"
},
"libs/auto-confirm": {
"name": "@bitwarden/auto-confirm",
"version": "0.0.1",
"license": "GPL-3.0"
},
"libs/billing": {
"name": "@bitwarden/billing",
"version": "0.0.0",
@ -4598,6 +4603,10 @@
"resolved": "libs/auth",
"link": true
},
"node_modules/@bitwarden/auto-confirm": {
"resolved": "libs/auto-confirm",
"link": true
},
"node_modules/@bitwarden/billing": {
"resolved": "libs/billing",
"link": true

View File

@ -24,6 +24,7 @@
"@bitwarden/assets/svg": ["libs/assets/src/svg/index.ts"],
"@bitwarden/auth/angular": ["./libs/auth/src/angular"],
"@bitwarden/auth/common": ["./libs/auth/src/common"],
"@bitwarden/auto-confirm": ["libs/auto-confirm/src/index.ts"],
"@bitwarden/billing": ["./libs/billing/src"],
"@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"],
"@bitwarden/browser/*": ["./apps/browser/src/*"],