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 }}
+ 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 }} +
+ + + + + {{ + "websiteItemLabel" | i18n: i + 1 + }} + +
{{ domain }}
+
+ +
+
+ +
+
+ + + +
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],