diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 9a430654a0a..ce4c5c76b81 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4459,7 +4459,7 @@ "message": "Common formats", "description": "Label indicating the most common import formats" }, - "uriMatchDefaultStrategyHint": { + "uriMatchDefaultStrategyHint": { "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, @@ -5714,5 +5714,9 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." } } diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 47a5e8fec4c..80e453e9e83 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { @@ -105,6 +106,7 @@ describe("OverlayBackground", () => { let platformUtilsService: MockProxy; let enablePasskeysMock$: BehaviorSubject; let vaultSettingsServiceMock: MockProxy; + const policyService = mock(); let fido2ActiveRequestManager: Fido2ActiveRequestManager; let selectedThemeMock$: BehaviorSubject; let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; @@ -156,7 +158,11 @@ describe("OverlayBackground", () => { fakeStateProvider = new FakeStateProvider(accountService); showFaviconsMock$ = new BehaviorSubject(true); neverDomainsMock$ = new BehaviorSubject({}); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); domainSettingsService.showFavicons$ = showFaviconsMock$; domainSettingsService.neverDomains$ = neverDomainsMock$; logService = mock(); diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 40dd013bbf4..add53a0cd33 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -265,6 +265,9 @@ [disabled]="option.disabled" > + + {{ "settingDisabledByPolicy" | i18n }} + {{ hints[0] | i18n }} diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index 3d2d605c13f..c3b5915a10a 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -26,6 +26,7 @@ import { 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { @@ -140,6 +141,8 @@ export class AutofillComponent implements OnInit { defaultUriMatch: new FormControl(), }); + protected isDefaultUriMatchDisabledByPolicy = false; + advancedOptionWarningMap: Partial>; enableAutofillOnPageLoad: boolean = false; enableInlineMenu: boolean = false; @@ -174,6 +177,7 @@ export class AutofillComponent implements OnInit { private accountService: AccountService, private autofillBrowserSettingsService: AutofillBrowserSettingsService, private restrictedItemTypesService: RestrictedItemTypesService, + private policyService: PolicyService, ) { this.autofillOnPageLoadOptions = [ { name: this.i18nService.t("autoFillOnPageLoadYes"), value: true }, @@ -302,7 +306,7 @@ export class AutofillComponent implements OnInit { }); const defaultUriMatch = await firstValueFrom( - this.domainSettingsService.defaultUriMatchStrategy$, + this.domainSettingsService.resolvedDefaultUriMatchStrategy$, ); this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch; @@ -310,6 +314,8 @@ export class AutofillComponent implements OnInit { emitEvent: false, }); + this.applyUriMatchPolicy(); + this.additionalOptionsForm.controls.enableContextMenuItem.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((value) => { @@ -524,6 +530,20 @@ export class AutofillComponent implements OnInit { await this.updateDefaultBrowserAutofillDisabled(); }; + private applyUriMatchPolicy() { + this.domainSettingsService.defaultUriMatchStrategyPolicy$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + if (value !== null) { + this.isDefaultUriMatchDisabledByPolicy = true; + this.additionalOptionsForm.controls.defaultUriMatch.disable({ emitEvent: false }); + } else { + this.isDefaultUriMatchDisabledByPolicy = false; + this.additionalOptionsForm.controls.defaultUriMatch.enable({ emitEvent: false }); + } + }); + } + private async handleAdvancedMatch( previous: UriMatchStrategySetting | null, current: UriMatchStrategySetting | null, diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 9b0424c5cdf..77e8c661d08 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; import { BehaviorSubject, of, Subject } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; @@ -86,6 +87,7 @@ describe("AutofillService", () => { const totpService = mock(); const eventCollectionService = mock(); const logService = mock(); + const policyService = mock(); const userVerificationService = mock(); const billingAccountProfileStateService = mock(); const platformUtilsService = mock(); @@ -138,7 +140,11 @@ describe("AutofillService", () => { userNotificationsSettings, messageListener, ); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); jest.spyOn(BrowserApi, "tabSendMessage"); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index ea0fb089690..2b37e0654ca 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -400,7 +400,7 @@ export default class AutofillService implements AutofillServiceInterface { * Gets the default URI match strategy setting from the domain settings service. */ async getDefaultUriMatchStrategy(): Promise { - return await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); + return await firstValueFrom(this.domainSettingsService.resolvedDefaultUriMatchStrategy$); } /** diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 7e2f94f7e3f..c5f4325526e 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -923,7 +923,11 @@ export default class MainBackground { this.userVerificationApiService = new UserVerificationApiService(this.apiService); - this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); + this.domainSettingsService = new DefaultDomainSettingsService( + this.stateProvider, + this.policyService, + this.accountService, + ); this.themeStateService = new DefaultThemeStateService(this.globalStateProvider); diff --git a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts index e4abea1d719..f2c3fe3317e 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { DomainSettingsService, DefaultDomainSettingsService, @@ -57,10 +58,15 @@ describe("ScriptInjectorService", () => { const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); let domainSettingsService: DomainSettingsService; + const policyService = mock(); beforeEach(() => { jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); domainSettingsService.blockedInteractionsUris$ = of({}); scriptInjectorService = new BrowserScriptInjectorService( diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 13684e56bda..a44ba81c40b 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -364,7 +364,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider], + deps: [StateProvider, PolicyService, AccountService], }), safeProvider({ provide: AbstractStorageService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c6eebac8463..bd0add19661 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -562,7 +562,11 @@ export class ServiceContainer { this.authService, ); - this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); + this.domainSettingsService = new DefaultDomainSettingsService( + this.stateProvider, + this.policyService, + this.accountService, + ); this.fileUploadService = new FileUploadService(this.logService, this.apiService); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 42141552472..b07e0865783 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -546,7 +546,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider], + deps: [StateProvider, PolicyServiceAbstraction, AccountService], }), safeProvider({ provide: CipherServiceAbstraction, diff --git a/libs/common/src/admin-console/enums/policy-type.enum.ts b/libs/common/src/admin-console/enums/policy-type.enum.ts index a4a860a2f3f..7c06bc41a66 100644 --- a/libs/common/src/admin-console/enums/policy-type.enum.ts +++ b/libs/common/src/admin-console/enums/policy-type.enum.ts @@ -17,5 +17,6 @@ export enum PolicyType { FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN. RestrictedItemTypes = 15, // Restricts item types that can be created within an organization + UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app } diff --git a/libs/common/src/autofill/services/domain-settings.service.spec.ts b/libs/common/src/autofill/services/domain-settings.service.spec.ts index 12a34b70913..53cf72d8e73 100644 --- a/libs/common/src/autofill/services/domain-settings.service.spec.ts +++ b/libs/common/src/autofill/services/domain-settings.service.spec.ts @@ -1,5 +1,8 @@ +import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; + import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; @@ -10,6 +13,7 @@ describe("DefaultDomainSettingsService", () => { let domainSettingsService: DomainSettingsService; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const policyService = mock(); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); const mockEquivalentDomains = [ @@ -19,7 +23,11 @@ describe("DefaultDomainSettingsService", () => { ]; beforeEach(() => { - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); jest.spyOn(domainSettingsService, "getUrlEquivalentDomains"); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index bc86f9b4d64..d6ab8851ad7 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -1,6 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { map, Observable } from "rxjs"; +import { combineLatest, map, Observable, switchMap, shareReplay } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type.enum"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { NeverDomains, @@ -87,6 +93,18 @@ export abstract class DomainSettingsService { defaultUriMatchStrategy$: Observable; setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise; + /** + * Org policy value for default for URI-matching + * strategies. Can be overridden by cipher-specific settings. + */ + defaultUriMatchStrategyPolicy$: Observable; + + /** + * Resolved (concerning user setting, org policy, etc) default for URI-matching + * strategies. Can be overridden by cipher-specific settings. + */ + resolvedDefaultUriMatchStrategy$: Observable; + /** * Helper function for the common resolution of a given URL against equivalent domains */ @@ -109,7 +127,15 @@ export class DefaultDomainSettingsService implements DomainSettingsService { private defaultUriMatchStrategyState: ActiveUserState; readonly defaultUriMatchStrategy$: Observable; - constructor(private stateProvider: StateProvider) { + readonly defaultUriMatchStrategyPolicy$: Observable; + + readonly resolvedDefaultUriMatchStrategy$: Observable; + + constructor( + private stateProvider: StateProvider, + private policyService: PolicyService, + private accountService: AccountService, + ) { this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS); this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true)); @@ -129,6 +155,31 @@ export class DefaultDomainSettingsService implements DomainSettingsService { this.defaultUriMatchStrategy$ = this.defaultUriMatchStrategyState.state$.pipe( map((x) => x ?? UriMatchStrategy.Domain), ); + + this.defaultUriMatchStrategyPolicy$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.UriMatchDefaults, userId), + ), + getFirstPolicy, + map((policy) => { + if (!policy?.enabled || policy?.data == null) { + return null; + } + const data = policy.data?.defaultUriMatchStrategy; + // Validate that data is a valid UriMatchStrategy value + return Object.values(UriMatchStrategy).includes(data) ? data : null; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.resolvedDefaultUriMatchStrategy$ = combineLatest([ + this.defaultUriMatchStrategy$, + this.defaultUriMatchStrategyPolicy$, + ]).pipe( + map(([userSettingValue, policySettingValue]) => policySettingValue || userSettingValue), + shareReplay({ bufferSize: 1, refCount: true }), + ); } async setShowFavicons(newValue: boolean): Promise { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 8de7f48c2ba..4bdc0d9b9fd 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -634,7 +634,9 @@ export class CipherService implements CipherServiceAbstraction { const equivalentDomains = await firstValueFrom( this.domainSettingsService.getUrlEquivalentDomains(url), ); - defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); + defaultMatch ??= await firstValueFrom( + this.domainSettingsService.resolvedDefaultUriMatchStrategy$, + ); const archiveFeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.PM19148_InnovationArchive, diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 3b1017ffe32..cf52dbc557f 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -215,7 +215,9 @@ export default { { provide: DomainSettingsService, useValue: { - defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith), + resolvedDefaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith), + defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.Domain), + defaultUriMatchStrategyPolicy$: new BehaviorSubject(null), }, }, { diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts index afbf1d86649..3aeeac6ca92 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts @@ -43,7 +43,7 @@ describe("AutofillOptionsComponent", () => { liveAnnouncer = mock(); platformUtilsService = mock(); domainSettingsService = mock(); - domainSettingsService.defaultUriMatchStrategy$ = new BehaviorSubject(null); + domainSettingsService.resolvedDefaultUriMatchStrategy$ = new BehaviorSubject(null); autofillSettingsService = mock(); autofillSettingsService.autofillOnPageLoadDefault$ = new BehaviorSubject(false); diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index 213fe227cdf..6a2b3e431ca 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -76,10 +76,12 @@ export class AutofillOptionsComponent implements OnInit { return this.cipherFormContainer.config.mode === "partial-edit"; } - protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$.pipe( - // The default match detection should only be shown when used on the browser - filter(() => this.platformUtilsService.getClientType() == ClientType.Browser), - ); + protected defaultMatchDetection$ = + this.domainSettingsService.resolvedDefaultUriMatchStrategy$.pipe( + // The default match detection should only be shown when used on the browser + filter(() => this.platformUtilsService.getClientType() == ClientType.Browser), + ); + protected autofillOnPageLoadEnabled$ = this.autofillSettingsService.autofillOnPageLoad$; protected autofillOptions: { label: string; value: boolean | null }[] = [