1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-15 16:05:03 +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 GitHub
parent 97312aaaa0
commit bb318ee22e
55 changed files with 1393 additions and 188 deletions

View File

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

View File

@@ -0,0 +1,70 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
/**
* This guard is intended to prevent members of an organization from accessing
* routes based on compliance with organization
* policies. e.g Emergency access, which is a non-organization
* feature is restricted by the Auto Confirm policy.
*/
export function organizationPolicyGuard(
featureCallback: (
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) => Observable<boolean>,
): CanActivateFn {
return async () => {
const router = inject(Router);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const accountService = inject(AccountService);
const policyService = inject(PolicyService);
const configService = inject(ConfigService);
const syncService = inject(SyncService);
const synced = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => syncService.lastSync$(userId)),
),
);
if (synced == null) {
await syncService.fullSync(false);
}
const compliant = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => featureCallback(userId, configService, policyService)),
tap((compliant) => {
if (typeof compliant !== "boolean") {
throw new Error("Feature callback must return a boolean.");
}
}),
),
);
if (!compliant) {
toastService.showToast({
variant: "error",
message: i18nService.t("noPageAccess"),
});
return router.createUrlTree(["/"]);
}
return compliant;
};
}

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

@@ -1,4 +1,5 @@
export * from "./account-security-nudge.service";
export * from "./auto-confirm-nudge.service";
export * from "./has-items-nudge.service";
export * from "./empty-vault-nudge.service";
export * from "./vault-settings-import-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,
NoOpNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
@@ -39,6 +40,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;
@@ -82,6 +84,7 @@ export class NudgesService {
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
[NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
};
/**
@@ -148,6 +151,7 @@ export class NudgesService {
NudgeType.EmptyVaultNudge,
NudgeType.DownloadBitwarden,
NudgeType.AutofillNudge,
NudgeType.AutoConfirmNudge,
];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {