1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[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
This commit is contained in:
Jordan Aasen
2025-10-29 12:55:23 -07:00
committed by GitHub
parent 94f778006f
commit c05ea23ce4
9 changed files with 749 additions and 18 deletions

View File

@@ -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"
},

View File

@@ -0,0 +1,68 @@
<bit-dialog>
<span bitDialogTitle>{{ "confirmAutofill" | i18n }}</span>
<div bitDialogContent>
<p bitTypography="body2">
{{ "confirmAutofillDesc" | i18n }}
</p>
@if (savedUrls.length === 1) {
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-4 tw-font-semibold">
{{ "savedWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="success" icon="bwi-globe">
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="savedUrls[0]">
{{ savedUrls[0] }}
</div>
</bit-callout>
}
@if (savedUrls.length > 1) {
<div class="tw-flex tw-justify-between tw-items-center tw-mt-4 tw-mb-1 tw-pt-2">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-font-semibold">
{{ "savedWebsites" | i18n: savedUrls.length }}
</p>
<button
*ngIf="!savedUrlsExpanded"
type="button"
bitLink
class="tw-text-sm tw-font-bold tw-cursor-pointer"
(click)="viewAllSavedUrls()"
>
{{ "viewAll" | i18n }}
</button>
</div>
<div class="tw-pt-2" [ngClass]="savedUrlsListClass">
<div class="-tw-mt-2" *ngFor="let url of savedUrls">
<bit-callout [title]="null" type="success" icon="bwi-globe">
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="url">
{{ url }}
</div>
</bit-callout>
</div>
</div>
}
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-5 tw-font-semibold">
{{ "currentWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="warning" icon="bwi-globe">
<div [appA11yTitle]="currentUrl" class="tw-font-mono tw-line-clamp-1 tw-break-all">
{{ currentUrl }}
</div>
</bit-callout>
<div class="tw-flex tw-justify-center tw-flex-col tw-gap-3 tw-mt-6">
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
{{ "autofillAndAddWebsite" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
{{ "autofillWithoutAdding" | i18n }}
</button>
<button
type="button"
bitLink
linkType="secondary"
(click)="close()"
class="tw-mt-2 tw-font-bold tw-text-sm tw-justify-center tw-text-center"
>
{{ "doNotAutofill" | i18n }}
</button>
</div>
</div>
</bit-dialog>

View File

@@ -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<AutofillConfirmationDialogComponent>;
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);
});
});

View File

@@ -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<AutofillConfirmationDialogParams>,
) {
return dialogService.open<AutofillConfirmationDialogResultType>(
AutofillConfirmationDialogComponent,
{ ...config },
);
}
}

View File

@@ -13,9 +13,17 @@
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
<button type="button" bitMenuItem *ngIf="canEdit && isLogin" (click)="doAutofillAndSave()">
{{ "fillAndSave" | i18n }}
</button>
<!-- Autofill confirmation handles both 'autofill' and 'autofill and save' so no need to show both -->
@if (!(showAutofillConfirmation$ | async)) {
<button
type="button"
bitMenuItem
*ngIf="canEdit && isLogin"
(click)="doAutofillAndSave()"
>
{{ "fillAndSave" | i18n }}
</button>
}
</ng-container>
</ng-container>
<ng-container *ngIf="showViewOption">

View File

@@ -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<ItemMoreOptionsComponent>;
let component: ItemMoreOptionsComponent;
const dialogService = {
openSimpleDialog: jest.fn().mockResolvedValue(true),
open: jest.fn(),
};
const featureFlag$ = new BehaviorSubject<boolean>(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<UriMatchStrategySetting>(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<PasswordRepromptService>() },
{
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);
});
});

View File

@@ -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<CipherViewLike>(undefined);
private _cipher$ = new BehaviorSubject<CipherViewLike>({} 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",
),

View File

@@ -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<boolean> = this._hasSearchText.pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**
* Observable that indicates whether a filter or search text is currently applied to the ciphers.
*/