diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 36d69fb09f5..2ace8ff8b96 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -4801,6 +4801,15 @@
"accountSecurity": {
"message": "Account security"
},
+ "phishingBlocker": {
+ "message": "Phishing Blocker"
+ },
+ "enablePhishingDetection": {
+ "message": "Phishing detection"
+ },
+ "enablePhishingDetectionDesc": {
+ "message": "Display warning before accessing suspected phishing sites"
+ },
"notifications": {
"message": "Notifications"
},
diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html
index b5d725b4a82..bb6b141c6c5 100644
--- a/apps/browser/src/auth/popup/settings/account-security.component.html
+++ b/apps/browser/src/auth/popup/settings/account-security.component.html
@@ -128,6 +128,20 @@
+
+
+ {{ "phishingBlocker" | i18n }}
+
+
+
+ {{
+ "enablePhishingDetection" | i18n
+ }}
+ {{ "enablePhishingDetectionDesc" | i18n }}
+
+
+
+
{{ "otherOptions" | i18n }}
diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts
index d0ab4793301..0f799fe7d4d 100644
--- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts
+++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts
@@ -3,9 +3,10 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
-import { firstValueFrom, of } from "rxjs";
+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 { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -14,12 +15,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
+import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
+import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
VaultTimeoutAction,
} from "@bitwarden/common/key-management/vault-timeout";
+import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -27,12 +31,12 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
-import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, ToastService } from "@bitwarden/components";
+import { newGuid } from "@bitwarden/guid";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -54,18 +58,27 @@ describe("AccountSecurityComponent", () => {
let component: AccountSecurityComponent;
let fixture: ComponentFixture;
- const mockUserId = Utils.newGuid() as UserId;
+ const mockUserId = newGuid() as UserId;
+
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
- const vaultTimeoutSettingsService = mock();
+ const apiService = mock();
+ const billingService = mock();
const biometricStateService = mock();
- const policyService = mock();
- const pinServiceAbstraction = mock();
- const keyService = mock();
- const validationService = mock();
- const dialogService = mock();
- const platformUtilsService = mock();
- const lockService = mock();
const configService = mock();
+ const dialogService = mock();
+ const keyService = mock();
+ const lockService = mock();
+ const policyService = mock();
+ const phishingDetectionSettingsService = mock();
+ const pinServiceAbstraction = mock();
+ const platformUtilsService = mock();
+ const validationService = mock();
+ const vaultNudgesService = mock();
+ const vaultTimeoutSettingsService = mock();
+
+ // Mock subjects to control the phishing detection observables
+ let phishingAvailableSubject: BehaviorSubject;
+ let phishingEnabledSubject: BehaviorSubject;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -73,29 +86,38 @@ describe("AccountSecurityComponent", () => {
{ provide: AccountService, useValue: accountService },
{ provide: AccountSecurityComponent, useValue: mock() },
{ provide: ActivatedRoute, useValue: mock() },
+ { provide: ApiService, useValue: apiService },
+ {
+ provide: BillingAccountProfileStateService,
+ useValue: billingService,
+ },
{ provide: BiometricsService, useValue: mock() },
{ provide: BiometricStateService, useValue: biometricStateService },
+ { provide: CipherService, useValue: mock() },
+ { provide: CollectionService, useValue: mock() },
+ { provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
{ provide: EnvironmentService, useValue: mock() },
{ provide: I18nService, useValue: mock() },
- { provide: MessageSender, useValue: mock() },
{ provide: KeyService, useValue: keyService },
+ { provide: LockService, useValue: lockService },
+ { provide: LogService, useValue: mock() },
+ { provide: MessageSender, useValue: mock() },
+ { provide: NudgesService, useValue: vaultNudgesService },
+ { provide: OrganizationService, useValue: mock() },
{ provide: PinServiceAbstraction, useValue: pinServiceAbstraction },
+ {
+ provide: PhishingDetectionSettingsServiceAbstraction,
+ useValue: phishingDetectionSettingsService,
+ },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: PolicyService, useValue: policyService },
{ provide: PopupRouterCacheService, useValue: mock() },
+ { provide: StateProvider, useValue: mock() },
{ provide: ToastService, useValue: mock() },
{ provide: UserVerificationService, useValue: mock() },
- { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
- { provide: StateProvider, useValue: mock() },
- { provide: CipherService, useValue: mock() },
- { provide: ApiService, useValue: mock() },
- { provide: LogService, useValue: mock() },
- { provide: OrganizationService, useValue: mock() },
- { provide: CollectionService, useValue: mock() },
{ provide: ValidationService, useValue: validationService },
- { provide: LockService, useValue: lockService },
- { provide: ConfigService, useValue: configService },
+ { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
],
})
.overrideComponent(AccountSecurityComponent, {
@@ -110,10 +132,13 @@ describe("AccountSecurityComponent", () => {
})
.compileComponents();
- fixture = TestBed.createComponent(AccountSecurityComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
-
+ apiService.getProfile.mockResolvedValue(
+ mock({
+ id: mockUserId,
+ creationDate: new Date().toISOString(),
+ }),
+ );
+ vaultNudgesService.showNudgeSpotlight$.mockReturnValue(of(false));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(
of(VaultTimeoutStringType.OnLocked),
);
@@ -123,8 +148,25 @@ describe("AccountSecurityComponent", () => {
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock),
);
+ vaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(of([]));
biometricStateService.promptAutomatically$ = of(false);
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
+ configService.getFeatureFlag$.mockReturnValue(of(false));
+ billingService.hasPremiumPersonally$.mockReturnValue(of(true));
+
+ policyService.policiesByType$.mockReturnValue(of([null]));
+
+ // Mock readonly observables for phishing detection using BehaviorSubjects so
+ // tests can push different values after component creation.
+ phishingAvailableSubject = new BehaviorSubject(true);
+ phishingEnabledSubject = new BehaviorSubject(true);
+
+ (phishingDetectionSettingsService.available$ as any) = phishingAvailableSubject.asObservable();
+ (phishingDetectionSettingsService.enabled$ as any) = phishingEnabledSubject.asObservable();
+
+ fixture = TestBed.createComponent(AccountSecurityComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
});
afterEach(() => {
@@ -233,6 +275,59 @@ describe("AccountSecurityComponent", () => {
expect(pinInputElement).toBeNull();
});
+ describe("phishing detection UI and setting", () => {
+ it("updates phishing detection setting when form value changes", async () => {
+ policyService.policiesByType$.mockReturnValue(of([null]));
+
+ phishingAvailableSubject.next(true);
+ phishingEnabledSubject.next(true);
+
+ // Init component
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ // Initial form value should match enabled$ observable defaulting to true
+ expect(component.form.controls.enablePhishingDetection.value).toBe(true);
+
+ // Change the form value to false
+ component.form.controls.enablePhishingDetection.setValue(false);
+ fixture.detectChanges();
+ // Wait briefly to allow any debounced or async valueChanges handlers to run
+ // fixture.whenStable() does not work here
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(phishingDetectionSettingsService.setEnabled).toHaveBeenCalledWith(mockUserId, false);
+ });
+
+ it("shows phishing detection element when available$ is true", async () => {
+ policyService.policiesByType$.mockReturnValue(of([null]));
+ phishingAvailableSubject.next(true);
+ phishingEnabledSubject.next(true);
+
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ const phishingDetectionElement = fixture.debugElement.query(
+ By.css("#phishingDetectionAction"),
+ );
+ expect(phishingDetectionElement).not.toBeNull();
+ });
+
+ it("hides phishing detection element when available$ is false", async () => {
+ policyService.policiesByType$.mockReturnValue(of([null]));
+ phishingAvailableSubject.next(false);
+ phishingEnabledSubject.next(true);
+
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ const phishingDetectionElement = fixture.debugElement.query(
+ By.css("#phishingDetectionAction"),
+ );
+ expect(phishingDetectionElement).toBeNull();
+ });
+ });
+
describe("updateBiometric", () => {
let browserApiSpy: jest.SpyInstance;
diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts
index 4ff29c8853e..7c36754c894 100644
--- a/apps/browser/src/auth/popup/settings/account-security.component.ts
+++ b/apps/browser/src/auth/popup/settings/account-security.component.ts
@@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
@@ -62,6 +63,7 @@ import {
SelectModule,
TypographyModule,
ToastService,
+ SwitchComponent,
} from "@bitwarden/components";
import {
KeyService,
@@ -110,6 +112,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
SpotlightComponent,
TypographyModule,
SessionTimeoutInputLegacyComponent,
+ SwitchComponent,
],
})
export class AccountSecurityComponent implements OnInit, OnDestroy {
@@ -130,6 +133,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
pinLockWithMasterPassword: false,
biometric: false,
enableAutoBiometricsPrompt: true,
+ enablePhishingDetection: true,
});
protected showAccountSecurityNudge$: Observable =
@@ -141,6 +145,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
);
protected readonly consolidatedSessionTimeoutComponent$: Observable;
+ protected readonly phishingDetectionAvailable$: Observable;
protected refreshTimeoutSettings$ = new BehaviorSubject(undefined);
private destroy$ = new Subject();
@@ -167,10 +172,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private vaultNudgesService: NudgesService,
private validationService: ValidationService,
private logService: LogService,
+ private phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction,
) {
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
);
+
+ // Check if user phishing detection available
+ this.phishingDetectionAvailable$ = this.phishingDetectionSettingsService.available$;
}
async ngOnInit() {
@@ -251,6 +260,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
enableAutoBiometricsPrompt: await firstValueFrom(
this.biometricStateService.promptAutomatically$,
),
+ enablePhishingDetection: await firstValueFrom(this.phishingDetectionSettingsService.enabled$),
};
this.form.patchValue(initialValues, { emitEvent: false });
@@ -361,6 +371,16 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
)
.subscribe();
+ this.form.controls.enablePhishingDetection.valueChanges
+ .pipe(
+ concatMap(async (enabled) => {
+ const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
+ await this.phishingDetectionSettingsService.setEnabled(userId, enabled);
+ }),
+ takeUntil(this.destroy$),
+ )
+ .subscribe();
+
this.refreshTimeoutSettings$
.pipe(
switchMap(() =>
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index 2540571abb0..7b509380f6d 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -82,7 +82,9 @@ import {
import { isUrlInList } from "@bitwarden/common/autofill/utils";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
+import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
+import { PhishingDetectionSettingsService } from "@bitwarden/common/dirt/services/phishing-detection/phishing-detection-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import {
@@ -497,6 +499,7 @@ export default class MainBackground {
// DIRT
private phishingDataService: PhishingDataService;
+ private phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction;
constructor() {
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
@@ -1475,12 +1478,18 @@ export default class MainBackground {
this.platformUtilsService,
);
- PhishingDetectionService.initialize(
+ this.phishingDetectionSettingsService = new PhishingDetectionSettingsService(
this.accountService,
this.billingAccountProfileStateService,
this.configService,
+ this.organizationService,
+ this.stateProvider,
+ );
+
+ PhishingDetectionService.initialize(
this.logService,
this.phishingDataService,
+ this.phishingDetectionSettingsService,
messageListener,
);
diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts
index e33b4b1b4f1..ceb18bd1573 100644
--- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts
+++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts
@@ -1,9 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Observable, of } from "rxjs";
-import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
-import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageListener } from "@bitwarden/messaging";
@@ -11,17 +9,12 @@ import { PhishingDataService } from "./phishing-data.service";
import { PhishingDetectionService } from "./phishing-detection.service";
describe("PhishingDetectionService", () => {
- let accountService: AccountService;
- let billingAccountProfileStateService: BillingAccountProfileStateService;
- let configService: ConfigService;
let logService: LogService;
let phishingDataService: MockProxy;
let messageListener: MockProxy;
+ let phishingDetectionSettingsService: MockProxy;
beforeEach(() => {
- accountService = { getAccount$: jest.fn(() => of(null)) } as any;
- billingAccountProfileStateService = {} as any;
- configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
phishingDataService = mock();
messageListener = mock({
@@ -29,16 +22,17 @@ describe("PhishingDetectionService", () => {
return new Observable();
},
});
+ phishingDetectionSettingsService = mock({
+ on$: of(true),
+ });
});
it("should initialize without errors", () => {
expect(() => {
PhishingDetectionService.initialize(
- accountService,
- billingAccountProfileStateService,
- configService,
logService,
phishingDataService,
+ phishingDetectionSettingsService,
messageListener,
);
}).not.toThrow();
@@ -61,6 +55,7 @@ describe("PhishingDetectionService", () => {
// logService,
// phishingDataService,
// messageListener,
+ // phishingDetectionSettingsService,
// );
// });
@@ -81,6 +76,7 @@ describe("PhishingDetectionService", () => {
// logService,
// phishingDataService,
// messageListener,
+ // phishingDetectionSettingsService,
// );
// });
});
diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts
index 4917e740be8..e04d08559ab 100644
--- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts
+++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts
@@ -1,21 +1,16 @@
import {
- combineLatest,
concatMap,
distinctUntilChanged,
EMPTY,
filter,
map,
merge,
- of,
Subject,
switchMap,
tap,
} from "rxjs";
-import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
-import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
-import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
@@ -50,11 +45,9 @@ export class PhishingDetectionService {
private static _didInit = false;
static initialize(
- accountService: AccountService,
- billingAccountProfileStateService: BillingAccountProfileStateService,
- configService: ConfigService,
logService: LogService,
phishingDataService: PhishingDataService,
+ phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction,
messageListener: MessageListener,
) {
if (this._didInit) {
@@ -118,22 +111,9 @@ export class PhishingDetectionService {
.messages$(PHISHING_DETECTION_CANCEL_COMMAND)
.pipe(switchMap((message) => BrowserApi.closeTab(message.tabId)));
- const activeAccountHasAccess$ = combineLatest([
- accountService.activeAccount$,
- configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
- ]).pipe(
- switchMap(([account, featureEnabled]) => {
- if (!account) {
- logService.debug("[PhishingDetectionService] No active account.");
- return of(false);
- }
- return billingAccountProfileStateService
- .hasPremiumFromAnySource$(account.id)
- .pipe(map((hasPremium) => hasPremium && featureEnabled));
- }),
- );
+ const phishingDetectionActive$ = phishingDetectionSettingsService.on$;
- const initSub = activeAccountHasAccess$
+ const initSub = phishingDetectionActive$
.pipe(
distinctUntilChanged(),
switchMap((activeUserHasAccess) => {
diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts
index bb89eff1147..39c53b7da56 100644
--- a/apps/browser/src/popup/services/services.module.ts
+++ b/apps/browser/src/popup/services/services.module.ts
@@ -41,6 +41,7 @@ import {
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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
AccountService,
@@ -67,6 +68,8 @@ import {
UserNotificationSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
+import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
+import { PhishingDetectionSettingsService } from "@bitwarden/common/dirt/services/phishing-detection/phishing-detection-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -512,6 +515,17 @@ const safeProviders: SafeProvider[] = [
useClass: UserNotificationSettingsService,
deps: [StateProvider],
}),
+ safeProvider({
+ provide: PhishingDetectionSettingsServiceAbstraction,
+ useClass: PhishingDetectionSettingsService,
+ deps: [
+ AccountService,
+ BillingAccountProfileStateService,
+ ConfigService,
+ OrganizationService,
+ StateProvider,
+ ],
+ }),
safeProvider({
provide: MessageListener,
useFactory: (subject: Subject>>, ngZone: NgZone) =>
diff --git a/libs/common/src/dirt/services/abstractions/phishing-detection-settings.service.abstraction.ts b/libs/common/src/dirt/services/abstractions/phishing-detection-settings.service.abstraction.ts
new file mode 100644
index 00000000000..6c915c2dcbe
--- /dev/null
+++ b/libs/common/src/dirt/services/abstractions/phishing-detection-settings.service.abstraction.ts
@@ -0,0 +1,37 @@
+import { Observable } from "rxjs";
+
+import { UserId } from "@bitwarden/user-core";
+
+/**
+ * Abstraction for phishing detection settings
+ */
+export abstract class PhishingDetectionSettingsServiceAbstraction {
+ /**
+ * An observable for whether phishing detection is available for the active user account.
+ *
+ * Access is granted only when the PhishingDetection feature flag is enabled and
+ * at least one of the following is true for the active account:
+ * - the user has a personal premium subscription
+ * - the user is a member of a Family org (ProductTierType.Families)
+ * - the user is a member of an Enterprise org with `usePhishingBlocker` enabled
+ *
+ * Note: Non-specified organization types (e.g., Team orgs) do not grant access.
+ */
+ abstract readonly available$: Observable;
+ /**
+ * An observable for whether phishing detection is on for the active user account
+ *
+ * This is true when {@link available$} is true and when {@link enabled$} is true
+ */
+ abstract readonly on$: Observable;
+ /**
+ * An observable for whether phishing detection is enabled
+ */
+ abstract readonly enabled$: Observable;
+ /**
+ * Sets whether phishing detection is enabled
+ *
+ * @param enabled True to enable, false to disable
+ */
+ abstract setEnabled: (userId: UserId, enabled: boolean) => Promise;
+}
diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts
new file mode 100644
index 00000000000..23e311d9445
--- /dev/null
+++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts
@@ -0,0 +1,203 @@
+import { mock, MockProxy } from "jest-mock-extended";
+import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
+
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
+import { ProductTierType } from "@bitwarden/common/billing/enums";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+
+import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
+import { UserId } from "../../../types/guid";
+
+import { PhishingDetectionSettingsService } from "./phishing-detection-settings.service";
+
+describe("PhishingDetectionSettingsService", () => {
+ // Mock services
+ let mockAccountService: MockProxy;
+ let mockBillingService: MockProxy;
+ let mockConfigService: MockProxy;
+ let mockOrganizationService: MockProxy;
+
+ // RxJS Subjects we control in the tests
+ let activeAccountSubject: BehaviorSubject;
+ let featureFlagSubject: BehaviorSubject;
+ let premiumStatusSubject: BehaviorSubject;
+ let organizationsSubject: BehaviorSubject;
+
+ let service: PhishingDetectionSettingsService;
+ let stateProvider: FakeStateProvider;
+
+ // Constant mock data
+ const familyOrg = mock({
+ canAccess: true,
+ isMember: true,
+ usersGetPremium: true,
+ productTierType: ProductTierType.Families,
+ usePhishingBlocker: true,
+ });
+ const teamOrg = mock({
+ canAccess: true,
+ isMember: true,
+ usersGetPremium: true,
+ productTierType: ProductTierType.Teams,
+ usePhishingBlocker: true,
+ });
+ const enterpriseOrg = mock({
+ canAccess: true,
+ isMember: true,
+ usersGetPremium: true,
+ productTierType: ProductTierType.Enterprise,
+ usePhishingBlocker: true,
+ });
+
+ const mockUserId = "mock-user-id" as UserId;
+ const account = mock({ id: mockUserId });
+ const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
+
+ beforeEach(() => {
+ // Initialize subjects
+ activeAccountSubject = new BehaviorSubject(null);
+ featureFlagSubject = new BehaviorSubject(false);
+ premiumStatusSubject = new BehaviorSubject(false);
+ organizationsSubject = new BehaviorSubject([]);
+
+ // Default implementations for required functions
+ mockAccountService = mock();
+ mockAccountService.activeAccount$ = activeAccountSubject.asObservable();
+
+ mockBillingService = mock();
+ mockBillingService.hasPremiumPersonally$.mockReturnValue(premiumStatusSubject.asObservable());
+
+ mockConfigService = mock();
+ mockConfigService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
+
+ mockOrganizationService = mock();
+ mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable());
+
+ stateProvider = new FakeStateProvider(accountService);
+ service = new PhishingDetectionSettingsService(
+ mockAccountService,
+ mockBillingService,
+ mockConfigService,
+ mockOrganizationService,
+ stateProvider,
+ );
+ });
+
+ // Helper to easily get the result of the observable we are testing
+ const getAccess = () => firstValueFrom(service.available$);
+
+ describe("enabled$", () => {
+ it("should default to true if an account is logged in", async () => {
+ activeAccountSubject.next(account);
+ const result = await firstValueFrom(service.enabled$);
+ expect(result).toBe(true);
+ });
+
+ it("should return the stored value", async () => {
+ activeAccountSubject.next(account);
+
+ await service.setEnabled(mockUserId, false);
+ const resultDisabled = await firstValueFrom(service.enabled$);
+ expect(resultDisabled).toBe(false);
+
+ await service.setEnabled(mockUserId, true);
+ const resultEnabled = await firstValueFrom(service.enabled$);
+ expect(resultEnabled).toBe(true);
+ });
+ });
+
+ describe("setEnabled", () => {
+ it("should update the stored value", async () => {
+ activeAccountSubject.next(account);
+ await service.setEnabled(mockUserId, false);
+ let result = await firstValueFrom(service.enabled$);
+ expect(result).toBe(false);
+
+ await service.setEnabled(mockUserId, true);
+ result = await firstValueFrom(service.enabled$);
+ expect(result).toBe(true);
+ });
+ });
+
+ it("returns false immediately when the feature flag is disabled, regardless of other conditions", async () => {
+ activeAccountSubject.next(account);
+ premiumStatusSubject.next(true);
+ organizationsSubject.next([familyOrg]);
+
+ featureFlagSubject.next(false);
+
+ await expect(getAccess()).resolves.toBe(false);
+ });
+
+ it("returns false if there is no active account present yet", async () => {
+ activeAccountSubject.next(null); // No active account
+ featureFlagSubject.next(true); // Flag is on
+
+ await expect(getAccess()).resolves.toBe(false);
+ });
+
+ it("returns true when feature flag is enabled and user has premium personally", async () => {
+ activeAccountSubject.next(account);
+ featureFlagSubject.next(true);
+ organizationsSubject.next([]);
+ premiumStatusSubject.next(true);
+
+ await expect(getAccess()).resolves.toBe(true);
+ });
+
+ it("returns true when feature flag is enabled and user is in a Family Organization", async () => {
+ activeAccountSubject.next(account);
+ featureFlagSubject.next(true);
+ premiumStatusSubject.next(false); // User has no personal premium
+
+ organizationsSubject.next([familyOrg]);
+
+ await expect(getAccess()).resolves.toBe(true);
+ });
+
+ it("returns true when feature flag is enabled and user is in an Enterprise org with phishing blocker enabled", async () => {
+ activeAccountSubject.next(account);
+ featureFlagSubject.next(true);
+ premiumStatusSubject.next(false);
+ organizationsSubject.next([enterpriseOrg]);
+
+ await expect(getAccess()).resolves.toBe(true);
+ });
+
+ it("returns false when user has no access through personal premium or organizations", async () => {
+ activeAccountSubject.next(account);
+ featureFlagSubject.next(true);
+ premiumStatusSubject.next(false);
+ organizationsSubject.next([teamOrg]); // Team org does not give access
+
+ await expect(getAccess()).resolves.toBe(false);
+ });
+
+ it("shares/caches the available$ result between multiple subscribers", async () => {
+ // Use a plain Subject for this test so we control when the premium observable emits
+ // and avoid the BehaviorSubject's initial emission which can race with subscriptions.
+ // Provide the Subject directly as the mock return value for the billing service
+ const oneTimePremium = new Subject();
+ mockBillingService.hasPremiumPersonally$.mockReturnValueOnce(oneTimePremium.asObservable());
+
+ activeAccountSubject.next(account);
+ featureFlagSubject.next(true);
+ organizationsSubject.next([]);
+
+ const p1 = firstValueFrom(service.available$);
+ const p2 = firstValueFrom(service.available$);
+
+ // Trigger the pipeline
+ oneTimePremium.next(true);
+
+ const [first, second] = await Promise.all([p1, p2]);
+
+ expect(first).toBe(true);
+ expect(second).toBe(true);
+ // The billing function should have been called at most once due to caching
+ expect(mockBillingService.hasPremiumPersonally$).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts
new file mode 100644
index 00000000000..36d50f60de7
--- /dev/null
+++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts
@@ -0,0 +1,116 @@
+import { combineLatest, Observable, of, switchMap } from "rxjs";
+import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators";
+
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
+import { ProductTierType } from "@bitwarden/common/billing/enums";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { UserId } from "@bitwarden/user-core";
+
+import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
+import { PhishingDetectionSettingsServiceAbstraction } from "../abstractions/phishing-detection-settings.service.abstraction";
+
+const ENABLE_PHISHING_DETECTION = new UserKeyDefinition(
+ PHISHING_DETECTION_DISK,
+ "enablePhishingDetection",
+ {
+ deserializer: (value: boolean) => value ?? true, // Default: enabled
+ clearOn: [],
+ },
+);
+
+export class PhishingDetectionSettingsService implements PhishingDetectionSettingsServiceAbstraction {
+ readonly available$: Observable;
+ readonly enabled$: Observable;
+ readonly on$: Observable;
+
+ constructor(
+ private accountService: AccountService,
+ private billingService: BillingAccountProfileStateService,
+ private configService: ConfigService,
+ private organizationService: OrganizationService,
+ private stateProvider: StateProvider,
+ ) {
+ this.available$ = this.buildAvailablePipeline$().pipe(
+ distinctUntilChanged(),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ );
+ this.enabled$ = this.buildEnabledPipeline$().pipe(
+ distinctUntilChanged(),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ );
+
+ this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
+ map(([available, enabled]) => available && enabled),
+ distinctUntilChanged(),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ );
+ }
+
+ async setEnabled(userId: UserId, enabled: boolean): Promise {
+ await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
+ }
+
+ /**
+ * Builds the observable pipeline to determine if phishing detection is available to the user
+ *
+ * @returns An observable pipeline that determines if phishing detection is available
+ */
+ private buildAvailablePipeline$(): Observable {
+ return combineLatest([
+ this.accountService.activeAccount$,
+ this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
+ ]).pipe(
+ switchMap(([account, featureEnabled]) => {
+ if (!account || !featureEnabled) {
+ return of(false);
+ }
+ return combineLatest([
+ this.billingService.hasPremiumPersonally$(account.id).pipe(catchError(() => of(false))),
+ this.organizationService.organizations$(account.id).pipe(catchError(() => of([]))),
+ ]).pipe(
+ map(([hasPremium, organizations]) => hasPremium || this.orgGrantsAccess(organizations)),
+ catchError(() => of(false)),
+ );
+ }),
+ );
+ }
+
+ /**
+ * Builds the observable pipeline to determine if phishing detection is enabled by the user
+ *
+ * @returns True if phishing detection is enabled for the active user
+ */
+ private buildEnabledPipeline$(): Observable {
+ return this.accountService.activeAccount$.pipe(
+ switchMap((account) => {
+ if (!account) {
+ return of(false);
+ }
+ return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
+ }),
+ map((enabled) => enabled ?? true),
+ );
+ }
+
+ /**
+ * Determines if any of the user's organizations grant access to phishing detection
+ *
+ * @param organizations The organizations the user is a member of
+ * @returns True if any organization grants access to phishing detection
+ */
+ private orgGrantsAccess(organizations: Organization[]): boolean {
+ return organizations.some((org) => {
+ if (!org.canAccess || !org.isMember || !org.usersGetPremium) {
+ return false;
+ }
+ return (
+ org.productTierType === ProductTierType.Families ||
+ (org.productTierType === ProductTierType.Enterprise && org.usePhishingBlocker)
+ );
+ });
+ }
+}