1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 18:13:29 +00:00

Ac/pm 26364 extension UI for auto confirm (#17258)

* create nav link for auto confirm in settings page

* wip

* WIP

* create auto confirm library

* migrate auto confirm files to lib

* update imports

* fix tests

* fix nudge

* cleanup, add documentation

* clean up

* cleanup

* fix import

* fix more imports

* add tests

* design changes

* fix tests

* fix tw issue

* fix typo, add tests

* CR feedback

* more clean up, fix race condition

* CR feedback, cache policies, refactor tests

* run prettier with updated version

* clean up duplicate logic

* clean up

* fix test

* add missing prop for test mock

* clean up
This commit is contained in:
Brandon Treston
2026-01-07 15:27:41 -05:00
committed by jaasen-livefront
parent 68d534a63e
commit 3f225119f8
55 changed files with 1393 additions and 188 deletions

View File

@@ -4811,6 +4811,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

@@ -8,6 +8,7 @@ import { firstValueFrom, of, BehaviorSubject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { NudgesService } from "@bitwarden/angular/vault";
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";
@@ -124,6 +125,12 @@ describe("AccountSecurityComponent", () => {
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
{
provide: AutomaticUserConfirmationService,
useValue: mock<AutomaticUserConfirmationService>(),
},
{ provide: ConfigService, useValue: configService },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
],
})

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,
@@ -90,6 +91,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";
@@ -332,6 +334,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";
@@ -40,11 +44,18 @@ import {
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service";
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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
AccountService,
@@ -745,6 +756,19 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiService,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: BrowserSessionTimeoutTypeService,

View File

@@ -82,6 +82,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

@@ -7,7 +7,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 {
@@ -65,13 +67,25 @@ export class SettingsV2Component {
),
);
showAdminBadge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) =>
this.nudgesService.showNudgeBadge$(NudgeType.AutoConfirmNudge, account.id),
),
);
showAutofillBadge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id)),
);
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 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,121 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnInit,
signal,
WritableSignal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom, map, Observable, of, switchMap, tap, 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 readonly formLoading: WritableSignal<boolean> = signal(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() {
const userId = await firstValueFrom(this.userId$);
const autoConfirmEnabled = (
await firstValueFrom(this.autoConfirmService.configuration$(userId))
).enabled;
this.adminForm.setValue({ autoConfirm: autoConfirmEnabled });
this.formLoading.set(false);
this.adminForm.controls.autoConfirm.valueChanges
.pipe(
switchMap((newValue) => {
if (newValue) {
return this.confirm();
}
return of(false);
}),
withLatestFrom(this.autoConfirmService.configuration$(userId)),
switchMap(([newValue, existingState]) =>
this.autoConfirmService.upsert(userId, {
...existingState,
enabled: newValue,
showBrowserNotification: false,
}),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
private confirm(): Observable<boolean> {
return AutoConfirmWarningDialogComponent.open(this.dialogService).closed.pipe(
map((result) => result ?? false),
tap((result) => {
if (!result) {
this.adminForm.setValue({ autoConfirm: false }, { emitEvent: false });
}
}),
);
}
async dismissSpotlight() {
const userId = await firstValueFrom(this.userId$);
const state = await firstValueFrom(this.autoConfirmService.configuration$(userId));
await this.autoConfirmService.upsert(userId, { ...state, showBrowserNotification: false });
}
}