diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 06f11406b68..6d93ce69258 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -2324,6 +2324,9 @@
"message": "Domains",
"description": "A category title describing the concept of web domains"
},
+ "disabledDomains": {
+ "message": "Disabled domains"
+ },
"excludedDomains": {
"message": "Excluded domains"
},
@@ -2333,6 +2336,12 @@
"excludedDomainsDescAlt": {
"message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect."
},
+ "disabledDomainsDesc": {
+ "message": "Bitwarden will be disabled for these domains. You must refresh the page for changes to take effect."
+ },
+ "disabledDomainsDescAlt": {
+ "message": "Bitwarden will be disabled for these domains on all logged in accounts. You must refresh the page for changes to take effect."
+ },
"websiteItemLabel": {
"message": "Website $number$ (URI)",
"placeholders": {
@@ -2351,6 +2360,9 @@
}
}
},
+ "disabledDomainsSavedSuccess": {
+ "message": "Excluded domain changes saved"
+ },
"excludedDomainsSavedSuccess": {
"message": "Excluded domain changes saved"
},
diff --git a/apps/browser/src/auth/popup/settings/account-security-v1.component.html b/apps/browser/src/auth/popup/settings/account-security-v1.component.html
index dff9675743f..f17e0cc1176 100644
--- a/apps/browser/src/auth/popup/settings/account-security-v1.component.html
+++ b/apps/browser/src/auth/popup/settings/account-security-v1.component.html
@@ -135,6 +135,14 @@
{{ "logOut" | i18n }}
+
+ {{ "disabledDomains" | i18n }}
+
+
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 e0dfde7be77..92044665fd5 100644
--- a/apps/browser/src/auth/popup/settings/account-security.component.html
+++ b/apps/browser/src/auth/popup/settings/account-security.component.html
@@ -118,6 +118,10 @@
{{ "logOut" | i18n }}
+
+ {{ "disabledDomains" | i18n }}
+
+
diff --git a/apps/browser/src/autofill/popup/settings/disabled-domains.component.html b/apps/browser/src/autofill/popup/settings/disabled-domains.component.html
new file mode 100644
index 00000000000..b72bb14e6b4
--- /dev/null
+++ b/apps/browser/src/autofill/popup/settings/disabled-domains.component.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+ {{
+ accountSwitcherEnabled ? ("disabledDomainsDescAlt" | i18n) : ("disabledDomainsDesc" | i18n)
+ }}
+
+
+
+ {{ "domainsTitle" | i18n }}
+ {{ disabledDomainsState?.length || 0 }}
+
+
+
+
+
+ = fieldsEditThreshold">{{
+ "websiteItemLabel" | i18n: i + 1
+ }}
+ = fieldsEditThreshold"
+ #uriInput
+ appInputVerbatim
+ bitInput
+ id="excludedDomain{{ i }}"
+ inputmode="url"
+ name="excludedDomain{{ i }}"
+ type="text"
+ (change)="fieldChange()"
+ [(ngModel)]="disabledDomainsState[i]"
+ />
+ {{ domain }}
+
+
+
+
+
+ {{ "addDomain" | i18n }}
+
+
+
+
+
+ {{ "save" | i18n }}
+
+
+
diff --git a/apps/browser/src/autofill/popup/settings/disabled-domains.component.ts b/apps/browser/src/autofill/popup/settings/disabled-domains.component.ts
new file mode 100644
index 00000000000..d32ce2c444f
--- /dev/null
+++ b/apps/browser/src/autofill/popup/settings/disabled-domains.component.ts
@@ -0,0 +1,207 @@
+import { CommonModule } from "@angular/common";
+import {
+ QueryList,
+ Component,
+ ElementRef,
+ OnDestroy,
+ AfterViewInit,
+ ViewChildren,
+} from "@angular/core";
+import { FormsModule } from "@angular/forms";
+import { RouterModule } from "@angular/router";
+import { Subject, takeUntil } from "rxjs";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
+import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import {
+ ButtonModule,
+ CardComponent,
+ FormFieldModule,
+ IconButtonModule,
+ ItemModule,
+ LinkModule,
+ SectionComponent,
+ SectionHeaderComponent,
+ TypographyModule,
+} from "@bitwarden/components";
+
+import { enableAccountSwitching } from "../../../platform/flags";
+import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
+import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
+import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
+import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
+
+@Component({
+ selector: "app-disabled-domains",
+ templateUrl: "disabled-domains.component.html",
+ standalone: true,
+ imports: [
+ ButtonModule,
+ CardComponent,
+ CommonModule,
+ FormFieldModule,
+ FormsModule,
+ IconButtonModule,
+ ItemModule,
+ JslibModule,
+ LinkModule,
+ PopOutComponent,
+ PopupFooterComponent,
+ PopupHeaderComponent,
+ PopupPageComponent,
+ RouterModule,
+ SectionComponent,
+ SectionHeaderComponent,
+ TypographyModule,
+ ],
+})
+export class DisabledDomainsComponent implements AfterViewInit, OnDestroy {
+ @ViewChildren("uriInput") uriInputElements: QueryList>;
+
+ accountSwitcherEnabled = false;
+ dataIsPristine = true;
+ isLoading = false;
+ disabledDomainsState: string[] = [];
+ storedDisabledDomains: string[] = [];
+ // How many fields should be non-editable before editable fields
+ fieldsEditThreshold: number = 0;
+
+ private destroy$ = new Subject();
+
+ constructor(
+ private domainSettingsService: DomainSettingsService,
+ private i18nService: I18nService,
+ private platformUtilsService: PlatformUtilsService,
+ ) {
+ this.accountSwitcherEnabled = enableAccountSwitching();
+ }
+
+ async ngAfterViewInit() {
+ this.domainSettingsService.disabledInteractionsUris$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((neverDomains: NeverDomains) => this.handleStateUpdate(neverDomains));
+
+ this.uriInputElements.changes.pipe(takeUntil(this.destroy$)).subscribe(({ last }) => {
+ this.focusNewUriInput(last);
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ handleStateUpdate(neverDomains: NeverDomains) {
+ if (neverDomains) {
+ this.storedDisabledDomains = Object.keys(neverDomains);
+ }
+
+ this.disabledDomainsState = [...this.storedDisabledDomains];
+
+ // Do not allow the first x (pre-existing) fields to be edited
+ this.fieldsEditThreshold = this.storedDisabledDomains.length;
+
+ this.dataIsPristine = true;
+ this.isLoading = false;
+ }
+
+ focusNewUriInput(elementRef: ElementRef) {
+ if (elementRef?.nativeElement) {
+ elementRef.nativeElement.focus();
+ }
+ }
+
+ async addNewDomain() {
+ // add empty field to the Domains list interface
+ this.disabledDomainsState.push("");
+
+ await this.fieldChange();
+ }
+
+ async removeDomain(i: number) {
+ this.disabledDomainsState.splice(i, 1);
+
+ // If a pre-existing field was dropped, lower the edit threshold
+ if (i < this.fieldsEditThreshold) {
+ this.fieldsEditThreshold--;
+ }
+
+ await this.fieldChange();
+ }
+
+ async fieldChange() {
+ if (this.dataIsPristine) {
+ this.dataIsPristine = false;
+ }
+ }
+
+ async saveChanges() {
+ if (this.dataIsPristine) {
+ return;
+ }
+
+ this.isLoading = true;
+
+ const newDisabledDomainsSaveState: NeverDomains = {};
+ const uniqueDisabledDomains = new Set(this.disabledDomainsState);
+
+ for (const uri of uniqueDisabledDomains) {
+ if (uri && uri !== "") {
+ const validatedHost = Utils.getHostname(uri);
+
+ if (!validatedHost) {
+ this.platformUtilsService.showToast(
+ "error",
+ null,
+ this.i18nService.t("excludedDomainsInvalidDomain", uri),
+ );
+
+ // Don't reset via `handleStateUpdate` to allow existing input value correction
+ this.isLoading = false;
+ return;
+ }
+
+ newDisabledDomainsSaveState[validatedHost] = null;
+ }
+ }
+
+ try {
+ const existingState = new Set(this.storedDisabledDomains);
+ const newState = new Set(Object.keys(newDisabledDomainsSaveState));
+ const stateIsUnchanged =
+ existingState.size === newState.size &&
+ new Set([...existingState, ...newState]).size === existingState.size;
+
+ // The subscriber updates don't trigger if `setNeverDomains` sets an equivalent state
+ if (stateIsUnchanged) {
+ // Reset UI state directly
+ const constructedNeverDomainsState = this.storedDisabledDomains.reduce(
+ (neverDomains, uri) => ({ ...neverDomains, [uri]: null }),
+ {},
+ );
+ this.handleStateUpdate(constructedNeverDomainsState);
+ } else {
+ await this.domainSettingsService.setDisabledInteractionsUris(newDisabledDomainsSaveState);
+ }
+
+ this.platformUtilsService.showToast(
+ "success",
+ null,
+ this.i18nService.t("disabledDomainsSavedSuccess"),
+ );
+ } catch {
+ this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError"));
+
+ // Don't reset via `handleStateUpdate` to preserve input values
+ this.isLoading = false;
+ }
+ }
+
+ trackByFunction(index: number, _: string) {
+ return index;
+ }
+}
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts
index b7bc5643ac4..42db437f089 100644
--- a/apps/browser/src/popup/app-routing.module.ts
+++ b/apps/browser/src/popup/app-routing.module.ts
@@ -76,6 +76,7 @@ import { Fido2V1Component } from "../autofill/popup/fido2/fido2-v1.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
+import { DisabledDomainsComponent } from "../autofill/popup/settings/disabled-domains.component";
import { ExcludedDomainsV1Component } from "../autofill/popup/settings/excluded-domains-v1.component";
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotificationsSettingsV1Component } from "../autofill/popup/settings/notifications-v1.component";
@@ -389,6 +390,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
+ {
+ path: "disabled-domains",
+ component: DisabledDomainsComponent,
+ canActivate: [authGuard],
+ data: { state: "disabled-domains" } satisfies RouteDataProperties,
+ },
...extensionRefreshSwap(ExcludedDomainsV1Component, ExcludedDomainsComponent, {
path: "excluded-domains",
canActivate: [authGuard],