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:
1
libs/angular/src/admin-console/guards/index.ts
Normal file
1
libs/angular/src/admin-console/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./org-policy.guard";
|
||||
70
libs/angular/src/admin-console/guards/org-policy.guard.ts
Normal file
70
libs/angular/src/admin-console/guards/org-policy.guard.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user