mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
add reprompt. fix logic (#17122)
This commit is contained in:
@@ -48,11 +48,13 @@
|
||||
</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>
|
||||
@if (!viewOnly) {
|
||||
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
|
||||
{{ "autofillAndAddWebsite" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
|
||||
{{ "autofillWithoutAdding" | i18n }}
|
||||
{{ (viewOnly ? "autofill" : "autofillWithoutAdding") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,6 +25,37 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"],
|
||||
};
|
||||
|
||||
async function createFreshFixture(options?: {
|
||||
params?: AutofillConfirmationDialogParams;
|
||||
viewOnly?: boolean;
|
||||
}) {
|
||||
const p = options?.params ?? params;
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutofillConfirmationDialogComponent],
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
{ provide: DIALOG_DATA, useValue: p },
|
||||
{ provide: DialogRef, useValue: dialogRef },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: DialogService, useValue: {} },
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
const freshFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
|
||||
const freshInstance = freshFixture.componentInstance;
|
||||
|
||||
// If needed, set viewOnly BEFORE first detectChanges so initial render reflects it.
|
||||
if (typeof options?.viewOnly !== "undefined") {
|
||||
freshInstance.viewOnly = options.viewOnly;
|
||||
}
|
||||
|
||||
freshFixture.detectChanges();
|
||||
return { fixture: freshFixture, component: freshInstance };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => {
|
||||
if (typeof value !== "string" || !value) {
|
||||
@@ -117,15 +148,7 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
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;
|
||||
|
||||
const { component: fresh } = await createFreshFixture({ params: newParams });
|
||||
expect(fresh.savedUrls).toEqual([]);
|
||||
expect(fresh.currentUrl).toBe("bitwarden.com");
|
||||
});
|
||||
@@ -189,4 +212,33 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
expect(btn).toBeFalsy();
|
||||
expect(component.savedUrlsExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillWithoutAdding")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show autofillWithoutAdding text on autofill button when viewOnly is true", async () => {
|
||||
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
|
||||
|
||||
const text = vf.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillWithoutAdding")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows autofill and save button when viewOnly is false", () => {
|
||||
component.viewOnly = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillAndAddWebsite")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show autofill and save button when viewOnly is true", async () => {
|
||||
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
|
||||
|
||||
const text = vf.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillAndAddWebsite")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
export interface AutofillConfirmationDialogParams {
|
||||
savedUrls?: string[];
|
||||
currentUrl: string;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const AutofillConfirmationDialogResult = Object.freeze({
|
||||
@@ -50,12 +51,14 @@ export class AutofillConfirmationDialogComponent {
|
||||
currentUrl: string = "";
|
||||
savedUrls: string[] = [];
|
||||
savedUrlsExpanded = false;
|
||||
viewOnly: boolean = false;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams,
|
||||
private dialogRef: DialogRef,
|
||||
) {
|
||||
this.currentUrl = Utils.getHostname(params.currentUrl);
|
||||
this.viewOnly = params.viewOnly ?? false;
|
||||
this.savedUrls =
|
||||
params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -57,6 +56,10 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
autofillAllowed$: new BehaviorSubject(true),
|
||||
};
|
||||
|
||||
const passwordRepromptService = {
|
||||
passwordRepromptCheck: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const uriMatchStrategy$ = new BehaviorSubject<UriMatchStrategySetting>(UriMatchStrategy.Domain);
|
||||
|
||||
const domainSettingsService = {
|
||||
@@ -110,7 +113,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } },
|
||||
{ provide: ToastService, useValue: { showToast: () => {} } },
|
||||
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
|
||||
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
|
||||
{ provide: PasswordRepromptService, useValue: passwordRepromptService },
|
||||
{
|
||||
provide: DomainSettingsService,
|
||||
useValue: domainSettingsService,
|
||||
@@ -140,102 +143,128 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
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" });
|
||||
describe("doAutofill", () => {
|
||||
it("calls the autofill service to autofill 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();
|
||||
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();
|
||||
});
|
||||
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);
|
||||
it("opens the autofill 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();
|
||||
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"]);
|
||||
});
|
||||
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);
|
||||
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();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
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);
|
||||
it("calls the autofill service to autofill when the user selects 'AutofilledOnly'", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
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);
|
||||
it("calls the autofill service to doAutofillAndSave when the user selects 'AutofillAndUrlAdded'", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
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" });
|
||||
it("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();
|
||||
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();
|
||||
});
|
||||
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);
|
||||
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();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const fillAndSaveButton = fixture.nativeElement.querySelector(
|
||||
"button[bitMenuItem]:not([disabled])",
|
||||
);
|
||||
const fillAndSaveButton = fixture.nativeElement.querySelector(
|
||||
"button[bitMenuItem]:not([disabled])",
|
||||
);
|
||||
|
||||
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
|
||||
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
|
||||
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
|
||||
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
|
||||
});
|
||||
|
||||
it("call the passwordService to passwordRepromptCheck if their cipher has password reprompt enabled", async () => {
|
||||
baseCipher.reprompt = 2; // Master Password reprompt enabled
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
|
||||
});
|
||||
|
||||
it("does nothing if the user fails master password reprompt", async () => {
|
||||
baseCipher.reprompt = 2; // Master Password reprompt enabled
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,6 +202,10 @@ export class ItemMoreOptionsComponent {
|
||||
async doAutofill() {
|
||||
const cipher = await this.cipherService.getFullCipherView(this.cipher);
|
||||
|
||||
if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
|
||||
|
||||
if (!showAutofillConfirmation) {
|
||||
@@ -236,6 +240,7 @@ export class ItemMoreOptionsComponent {
|
||||
data: {
|
||||
currentUrl: currentTab?.url || "",
|
||||
savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [],
|
||||
viewOnly: !this.cipher.edit,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user