From e333c0a8bcc6735bb8705d4ee5e334de11446e6a Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:49:31 -0700 Subject: [PATCH 1/4] Preserve export type across export source selections (#16922) --- .../vault-export.service.abstraction.ts | 27 +++++++++++ .../src/services/vault-export.service.ts | 29 +++++++++++- .../src/components/export.component.html | 2 +- .../src/components/export.component.ts | 45 ++++++++++++------- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts index e25fec6eb82..0d58f168671 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UserId, OrganizationId } from "@bitwarden/common/types/guid"; import { ExportedVault } from "../types"; @@ -5,6 +7,24 @@ import { ExportedVault } from "../types"; export const EXPORT_FORMATS = ["csv", "json", "encrypted_json", "zip"] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; +/** + * Options that determine which export formats are available + */ +export type FormatOptions = { + /** Whether the export is for the user's personal vault */ + isMyVault: boolean; +}; + +/** + * Metadata describing an available export format + */ +export type ExportFormatMetadata = { + /** Display name for the format (e.g., ".json", ".csv") */ + name: string; + /** The export format identifier */ + format: ExportFormat; +}; + export abstract class VaultExportServiceAbstraction { abstract getExport: ( userId: UserId, @@ -18,4 +38,11 @@ export abstract class VaultExportServiceAbstraction { password: string, onlyManagedCollections?: boolean, ) => Promise; + + /** + * Get available export formats based on vault context + * @param options Options determining which formats are available + * @returns Observable stream of available export formats + */ + abstract formats$(options: FormatOptions): Observable; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts index b601478d06d..38d71136006 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -9,7 +9,12 @@ import { ExportedVault } from "../types"; import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction"; import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction"; -import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction"; +import { + ExportFormat, + ExportFormatMetadata, + FormatOptions, + VaultExportServiceAbstraction, +} from "./vault-export.service.abstraction"; export class VaultExportService implements VaultExportServiceAbstraction { constructor( @@ -85,6 +90,26 @@ export class VaultExportService implements VaultExportServiceAbstraction { ); } + /** + * Get available export formats based on vault context + * @param options Options determining which formats are available + * @returns Observable stream of available export formats + */ + formats$(options: FormatOptions): Observable { + const baseFormats: ExportFormatMetadata[] = [ + { name: ".json", format: "json" }, + { name: ".csv", format: "csv" }, + { name: ".json (Encrypted)", format: "encrypted_json" }, + ]; + + // ZIP format with attachments is only available for individual vault exports + if (options.isMyVault) { + return of([...baseFormats, { name: ".zip (with attachments)", format: "zip" }]); + } + + return of(baseFormats); + } + /** Checks if the provided userId matches the currently authenticated user * @param userId The userId to check * @throws Error if the userId does not match the currently authenticated user diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index c638e5d7dde..f41375edd5a 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -35,7 +35,7 @@ {{ "fileFormat" | i18n }} - + diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 19921b35162..610f30c1f67 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -67,7 +67,11 @@ import { } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; -import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { + ExportedVault, + ExportFormatMetadata, + VaultExportServiceAbstraction, +} from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -231,11 +235,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { fileEncryptionType: [EncryptedExportType.AccountEncrypted], }); - formatOptions = [ - { name: ".json", value: "json" }, - { name: ".csv", value: "csv" }, - { name: ".json (Encrypted)", value: "encrypted_json" }, - ]; + /** + * Observable stream of available export format options + * Dynamically updates based on vault selection (My Vault vs Organization) + */ + formatOptions$: Observable; private destroy$ = new Subject(); private onlyManagedCollections = true; @@ -338,17 +342,28 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } private observeFormSelections(): void { - this.exportForm.controls.vaultSelector.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.organizationId = value !== "myVault" ? value : undefined; + // Set up dynamic format options based on vault selection + this.formatOptions$ = this.exportForm.controls.vaultSelector.valueChanges.pipe( + startWith(this.exportForm.controls.vaultSelector.value), + map((vaultSelection) => { + const isMyVault = vaultSelection === "myVault"; + // Update organizationId based on vault selection + this.organizationId = isMyVault ? undefined : vaultSelection; + return { isMyVault }; + }), + switchMap((options) => this.exportService.formats$(options)), + tap((formats) => { + // Preserve the current format selection if it's still available in the new format list + const currentFormat = this.exportForm.get("format").value; + const isFormatAvailable = formats.some((f) => f.format === currentFormat); - this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip"); - this.exportForm.get("format").setValue("json"); - if (value === "myVault") { - this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); + // Only reset to json if the current format is no longer available + if (!isFormatAvailable) { + this.exportForm.get("format").setValue("json"); } - }); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); } /** From 94f778006fb7d15d7e24aeff5a7702a7b994165a Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:49:48 -0500 Subject: [PATCH 2/4] Fix lint (#17113) --- .../policies/session-timeout-confirmation-never.component.ts | 2 ++ .../app/key-management/policies/session-timeout.component.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts index a909baf1c77..884cbd10cac 100644 --- a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [SharedModule], templateUrl: "./session-timeout-confirmation-never.component.html", diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts index 3e40b9f0d80..9c6129f64df 100644 --- a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts @@ -40,6 +40,8 @@ export class SessionTimeoutPolicy extends BasePolicyEditDefinition { const DEFAULT_HOURS = 8; const DEFAULT_MINUTES = 0; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "session-timeout.component.html", imports: [SharedModule], From c05ea23ce4a3e6361fc47a08e14817fd62f0def2 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:55:23 -0700 Subject: [PATCH 3/4] [PM-25083][26650][26651][26652] - Autofill confirmation dialog (#16835) * add autofill confirmation dialog * fix key * better handle bad uris * add specs * adjustments to autofill confirmation to include exact match dialog. fix gradient * update logic. add tests --- apps/browser/src/_locales/en/messages.json | 48 ++++ ...utofill-confirmation-dialog.component.html | 68 +++++ ...fill-confirmation-dialog.component.spec.ts | 192 ++++++++++++++ .../autofill-confirmation-dialog.component.ts | 100 ++++++++ .../item-more-options.component.html | 14 +- .../item-more-options.component.spec.ts | 241 ++++++++++++++++++ .../item-more-options.component.ts | 95 +++++-- .../services/vault-popup-items.service.ts | 7 + libs/common/src/enums/feature-flag.enum.ts | 2 + 9 files changed, 749 insertions(+), 18 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29601bfa70c..4f230dd9883 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -588,6 +588,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1031,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1691,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3276,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4050,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html new file mode 100644 index 00000000000..77801edc8fe --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html @@ -0,0 +1,68 @@ + + {{ "confirmAutofill" | i18n }} +
+

+ {{ "confirmAutofillDesc" | i18n }} +

+ @if (savedUrls.length === 1) { +

+ {{ "savedWebsite" | i18n }} +

+ +
+ {{ savedUrls[0] }} +
+
+ } + @if (savedUrls.length > 1) { +
+

+ {{ "savedWebsites" | i18n: savedUrls.length }} +

+ +
+
+
+ +
+ {{ url }} +
+
+
+
+ } +

+ {{ "currentWebsite" | i18n }} +

+ +
+ {{ currentUrl }} +
+
+
+ + + +
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts new file mode 100644 index 00000000000..1fe3dfaf25a --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -0,0 +1,192 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DIALOG_DATA, DialogRef, DialogService } from "@bitwarden/components"; + +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, + AutofillConfirmationDialogParams, +} from "./autofill-confirmation-dialog.component"; + +describe("AutofillConfirmationDialogComponent", () => { + let fixture: ComponentFixture; + let component: AutofillConfirmationDialogComponent; + + const dialogRef = { + close: jest.fn(), + } as unknown as DialogRef; + + const params: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com/path?q=1", + savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"], + }; + + beforeEach(async () => { + jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => { + if (typeof value !== "string" || !value) { + return ""; + } + try { + // handle non-URL host strings gracefully + if (!value.includes("://")) { + return value; + } + return new URL(value).hostname; + } catch { + return ""; + } + }); + + await TestBed.configureTestingModule({ + imports: [AutofillConfirmationDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: DIALOG_DATA, useValue: params }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogService, useValue: {} }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("normalizes currentUrl and savedUrls via Utils.getHostname", () => { + expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0)); + // current + expect(component.currentUrl).toBe("example.com"); + // saved + expect(component.savedUrls).toEqual([ + "one.example.com", + "two.example.com", + "not-a-url.example", + ]); + }); + + it("renders normalized values into the template (shallow check)", () => { + const text = fixture.nativeElement.textContent as string; + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + expect(text).toContain("not-a-url.example"); + }); + + it("emits Canceled on close()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["close"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled); + }); + + it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["autofillAndAddUrl"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + }); + + it("emits AutofilledOnly on autofillOnly()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["autofillOnly"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly); + }); + + it("applies collapsed list gradient class by default, then clears it after viewAllSavedUrls()", () => { + const initial = component["savedUrlsListClass"]; + expect(initial).toContain("gradient"); + + component["viewAllSavedUrls"](); + fixture.detectChanges(); + + const expanded = component["savedUrlsListClass"]; + expect(expanded).toBe(""); + }); + + it("handles empty savedUrls gracefully", async () => { + const newParams: AutofillConfirmationDialogParams = { + currentUrl: "https://bitwarden.com/help", + savedUrls: [], + }; + + const newFixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + const newInstance = newFixture.componentInstance; + + (newInstance as any).params = newParams; + const fresh = new AutofillConfirmationDialogComponent( + newParams as any, + dialogRef, + ) as AutofillConfirmationDialogComponent; + + expect(fresh.savedUrls).toEqual([]); + expect(fresh.currentUrl).toBe("bitwarden.com"); + }); + + it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", () => { + const localParams: AutofillConfirmationDialogParams = { + currentUrl: "https://sub.domain.tld/x", + }; + + const local = new AutofillConfirmationDialogComponent(localParams as any, dialogRef); + + expect(local.savedUrls).toEqual([]); + expect(local.currentUrl).toBe("sub.domain.tld"); + }); + + it("filters out falsy/invalid values from Utils.getHostname in savedUrls", () => { + (Utils.getHostname as jest.Mock).mockImplementationOnce(() => "example.com"); + (Utils.getHostname as jest.Mock) + .mockImplementationOnce(() => "ok.example") + .mockImplementationOnce(() => "") + .mockImplementationOnce(() => undefined as unknown as string); + + const edgeParams: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com", + savedUrls: ["https://ok.example", "://bad", "%%%"], + }; + + const edge = new AutofillConfirmationDialogComponent(edgeParams as any, dialogRef); + + expect(edge.currentUrl).toBe("example.com"); + expect(edge.savedUrls).toEqual(["ok.example"]); + }); + + it("renders one current-url callout and N saved-url callouts", () => { + const callouts = Array.from( + fixture.nativeElement.querySelectorAll("bit-callout"), + ) as HTMLElement[]; + expect(callouts.length).toBe(1 + params.savedUrls!.length); + }); + + it("renders normalized hostnames into the DOM text", () => { + const text = (fixture.nativeElement.textContent as string).replace(/\s+/g, " "); + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + }); + + it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => { + const findViewAll = () => + fixture.nativeElement.querySelector( + "button.tw-text-sm.tw-font-bold.tw-cursor-pointer", + ) as HTMLButtonElement | null; + + let btn = findViewAll(); + expect(btn).toBeTruthy(); + + btn!.click(); + fixture.detectChanges(); + + btn = findViewAll(); + expect(btn).toBeFalsy(); + expect(component.savedUrlsExpanded).toBe(true); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts new file mode 100644 index 00000000000..cc2fc546ae6 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts @@ -0,0 +1,100 @@ +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + ButtonModule, + DialogService, + DialogModule, + TypographyModule, + CalloutComponent, + LinkModule, +} from "@bitwarden/components"; + +export interface AutofillConfirmationDialogParams { + savedUrls?: string[]; + currentUrl: string; +} + +export const AutofillConfirmationDialogResult = Object.freeze({ + AutofillAndUrlAdded: "added", + AutofilledOnly: "autofilled", + Canceled: "canceled", +} as const); + +export type AutofillConfirmationDialogResultType = UnionOfValues< + typeof AutofillConfirmationDialogResult +>; + +@Component({ + templateUrl: "./autofill-confirmation-dialog.component.html", + imports: [ + ButtonModule, + CalloutComponent, + CommonModule, + DialogModule, + LinkModule, + TypographyModule, + JslibModule, + ], +}) +export class AutofillConfirmationDialogComponent { + AutofillConfirmationDialogResult = AutofillConfirmationDialogResult; + + currentUrl: string = ""; + savedUrls: string[] = []; + savedUrlsExpanded = false; + + constructor( + @Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams, + private dialogRef: DialogRef, + ) { + this.currentUrl = Utils.getHostname(params.currentUrl); + this.savedUrls = + params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? []; + } + + protected get savedUrlsListClass(): string { + return this.savedUrlsExpanded + ? "" + : `tw-relative + tw-max-h-24 + tw-overflow-hidden + after:tw-pointer-events-none after:tw-content-[''] + after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0 + after:tw-h-8 after:tw-bg-gradient-to-t + after:tw-from-background after:tw-to-transparent + `; + } + + protected viewAllSavedUrls() { + this.savedUrlsExpanded = true; + } + + protected close() { + this.dialogRef.close(AutofillConfirmationDialogResult.Canceled); + } + + protected autofillAndAddUrl() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + } + + protected autofillOnly() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly); + } + + static open( + dialogService: DialogService, + config: DialogConfig, + ) { + return dialogService.open( + AutofillConfirmationDialogComponent, + { ...config }, + ); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 3a48f7eb449..b05d19498ac 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -13,9 +13,17 @@ - + + @if (!(showAutofillConfirmation$ | async)) { + + } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts new file mode 100644 index 00000000000..15a9ba8f8e3 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -0,0 +1,241 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; + +import { ItemMoreOptionsComponent } from "./item-more-options.component"; + +describe("ItemMoreOptionsComponent", () => { + let fixture: ComponentFixture; + let component: ItemMoreOptionsComponent; + + const dialogService = { + openSimpleDialog: jest.fn().mockResolvedValue(true), + open: jest.fn(), + }; + const featureFlag$ = new BehaviorSubject(false); + const configService = { + getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()), + }; + const cipherService = { + getFullCipherView: jest.fn(), + encrypt: jest.fn(), + updateWithServer: jest.fn(), + softDeleteWithServer: jest.fn(), + }; + const autofillSvc = { + doAutofill: jest.fn(), + doAutofillAndSave: jest.fn(), + currentAutofillTab$: new BehaviorSubject<{ url?: string | null } | null>(null), + autofillAllowed$: new BehaviorSubject(true), + }; + + const uriMatchStrategy$ = new BehaviorSubject(UriMatchStrategy.Domain); + + const domainSettingsService = { + resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(), + }; + + const hasSearchText$ = new BehaviorSubject(false); + const vaultPopupItemsService = { + hasSearchText$: hasSearchText$.asObservable(), + }; + + const baseCipher = { + id: "cipher-1", + login: { + uris: [ + { uri: "https://one.example.com" }, + { uri: "" }, + { uri: undefined as unknown as string }, + { uri: "https://two.example.com/a" }, + ], + username: "user", + }, + favorite: false, + reprompt: 0, + type: CipherType.Login, + viewPassword: true, + edit: true, + } as any; + + beforeEach(waitForAsync(async () => { + jest.clearAllMocks(); + + cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c })); + + TestBed.configureTestingModule({ + imports: [ItemMoreOptionsComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: CipherService, useValue: cipherService }, + { provide: VaultPopupAutofillService, useValue: autofillSvc }, + + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: OrganizationService, useValue: { hasOrganizations: () => of(false) } }, + { + provide: CipherAuthorizationService, + useValue: { canDeleteCipher$: () => of(true), canCloneCipher$: () => of(true) }, + }, + { provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } }, + { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, + { provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } }, + { provide: ToastService, useValue: { showToast: () => {} } }, + { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, + { provide: PasswordRepromptService, useValue: mock() }, + { + provide: DomainSettingsService, + useValue: domainSettingsService, + }, + { + provide: VaultPopupItemsService, + useValue: vaultPopupItemsService, + }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + TestBed.overrideProvider(DialogService, { useValue: dialogService }); + await TestBed.compileComponents(); + fixture = TestBed.createComponent(ItemMoreOptionsComponent); + component = fixture.componentInstance; + component.cipher = baseCipher; + })); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockConfirmDialogResult(result: string) { + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue({ closed: of(result) } as any); + return openSpy; + } + + it("calls doAutofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(cipherService.getFullCipherView).toHaveBeenCalled(); + expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofill).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + false, + ); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("opens the confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => { + featureFlag$.next(true); + hasSearchText$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledTimes(1); + const args = openSpy.mock.calls[0][1]; + expect(args.data.currentUrl).toBe("https://page.example.com/path"); + expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]); + }); + + it("does nothing when the user cancels the autofill confirmation dialog", async () => { + featureFlag$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("autofills the item without adding the URL when the user selects 'AutofilledOnly'", async () => { + featureFlag$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("autofills the item and adds the URL when the user selects 'AutofillAndUrlAdded'", async () => { + featureFlag$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + + await component.doAutofill(); + + expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + }); + + it("only shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => { + featureFlag$.next(true); + uriMatchStrategy$.next(UriMatchStrategy.Exact); + hasSearchText$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1); + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => { + // Enable both feature flag and search text → makes showAutofillConfirmation$ true + featureFlag$.next(true); + hasSearchText$.next(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + const fillAndSaveButton = fixture.nativeElement.querySelector( + "button[bitMenuItem]:not([disabled])", + ); + + const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? ""; + expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 94016d2670f..40b6476053b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; @@ -11,8 +9,12 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; @@ -32,7 +34,12 @@ import { import { PasswordRepromptService } from "@bitwarden/vault"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -42,7 +49,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject({} as CipherViewLike); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -64,7 +71,7 @@ export class ItemMoreOptionsComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - showViewOption: boolean; + showViewOption = false; /** * Flag to hide the autofill menu options. Used for items that are @@ -73,10 +80,17 @@ export class ItemMoreOptionsComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - hideAutofillOptions: boolean; + hideAutofillOptions = false; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; + protected showAutofillConfirmation$ = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation), + this.vaultPopupItemsService.hasSearchText$, + ]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText)); + + protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; + /** * Observable that emits a boolean value indicating if the user is authorized to clone the cipher. * @protected @@ -146,6 +160,9 @@ export class ItemMoreOptionsComponent { private collectionService: CollectionService, private restrictedItemTypesService: RestrictedItemTypesService, private cipherArchiveService: CipherArchiveService, + private configService: ConfigService, + private vaultPopupItemsService: VaultPopupItemsService, + private domainSettingsService: DomainSettingsService, ) {} get canEdit() { @@ -177,14 +194,63 @@ export class ItemMoreOptionsComponent { return this.cipher.favorite ? "unfavorite" : "favorite"; } - async doAutofill() { - const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofill(cipher); - } - async doAutofillAndSave() { const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher); + } + + async doAutofill() { + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$); + + if (!showAutofillConfirmation) { + await this.vaultPopupAutofillService.doAutofill(cipher, false); + return; + } + + const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); + if (uriMatchStrategy === UriMatchStrategy.Exact) { + await this.dialogService.openSimpleDialog({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + return; + } + + const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); + + if (!currentTab?.url) { + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: { key: "errorGettingAutoFillData" }, + type: "danger", + }); + return; + } + + const ref = AutofillConfirmationDialogComponent.open(this.dialogService, { + data: { + currentUrl: currentTab?.url || "", + savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [], + }, + }); + + const result = await firstValueFrom(ref.closed); + + switch (result) { + case AutofillConfirmationDialogResult.Canceled: + return; + case AutofillConfirmationDialogResult.AutofilledOnly: + await this.vaultPopupAutofillService.doAutofill(cipher); + return; + case AutofillConfirmationDialogResult.AutofillAndUrlAdded: + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); + return; + } } async onView() { @@ -204,15 +270,14 @@ export class ItemMoreOptionsComponent { const cipher = await this.cipherService.getFullCipherView(this.cipher); cipher.favorite = !cipher.favorite; - const activeUserId = await firstValueFrom( + const activeUserId = (await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + )) as UserId; const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t( this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites", ), diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index a1820a975f1..afe9d61d5af 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -261,6 +261,13 @@ export class VaultPopupItemsService { this.remainingCiphers$.pipe(map(() => false)), ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 })); + /** Observable that indicates whether there is search text present. + */ + hasSearchText$: Observable = this._hasSearchText.pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + /** * Observable that indicates whether a filter or search text is currently applied to the ciphers. */ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 085731b034e..bfb40aff106 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -55,6 +55,7 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", + AutofillConfirmation = "pm-25083-autofill-confirm-from-search", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -102,6 +103,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, + [FeatureFlag.AutofillConfirmation]: FALSE, /* Auth */ [FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE, From b8921cb079e3cdc69819033eab9a6b2be8965df4 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:28:36 -0700 Subject: [PATCH 4/4] fix lint error (#17115) --- .../autofill-confirmation-dialog.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts index cc2fc546ae6..71c07ad8bfc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, Inject } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -33,6 +33,7 @@ export type AutofillConfirmationDialogResultType = UnionOfValues< @Component({ templateUrl: "./autofill-confirmation-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ButtonModule, CalloutComponent,