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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user