mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
[PM-30521] Add Autofill button to View Login screen for extension (#18766)
* adds autofill button for cipher view * adds tests * changes autofill function for non login types * adds top margin to autofill button * adds more top margin to autofill button * only shows autofill button when autofill is allowed (not in a popout) * add button type * updates _domainMatched to take a tab param, updates how the component is passed through to slot * fixes tests from rename * adds comment about autofill tab checking behavior * removes diff markers
This commit is contained in:
@@ -218,6 +218,8 @@ export class ItemMoreOptionsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
//this tab checking should be moved into the vault-popup-autofill service in case the current tab is changed
|
||||
//ticket: https://bitwarden.atlassian.net/browse/PM-32467
|
||||
const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$);
|
||||
|
||||
if (!currentTab?.url) {
|
||||
|
||||
@@ -11,7 +11,21 @@
|
||||
</popup-header>
|
||||
|
||||
@if (cipher) {
|
||||
<app-cipher-view [cipher]="cipher"></app-cipher-view>
|
||||
<app-cipher-view [cipher]="cipher">
|
||||
@if (showAutofillButton()) {
|
||||
<button
|
||||
type="button"
|
||||
class="tw-mt-4"
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
block
|
||||
(click)="doAutofill()"
|
||||
>
|
||||
{{ "autofill" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</app-cipher-view>
|
||||
}
|
||||
|
||||
<popup-footer slot="footer" *ngIf="showFooter$ | async">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, Subject } from "rxjs";
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -33,6 +34,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
@@ -47,6 +49,10 @@ import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-uti
|
||||
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
||||
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
|
||||
import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service";
|
||||
import {
|
||||
AutofillConfirmationDialogComponent,
|
||||
AutofillConfirmationDialogResult,
|
||||
} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component";
|
||||
|
||||
import { ViewComponent } from "./view.component";
|
||||
|
||||
@@ -62,6 +68,7 @@ describe("ViewComponent", () => {
|
||||
const mockNavigate = jest.fn();
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
const doAutofill = jest.fn().mockResolvedValue(true);
|
||||
const doAutofillAndSave = jest.fn().mockResolvedValue(true);
|
||||
const copy = jest.fn().mockResolvedValue(true);
|
||||
const back = jest.fn().mockResolvedValue(null);
|
||||
const openSimpleDialog = jest.fn().mockResolvedValue(true);
|
||||
@@ -69,6 +76,8 @@ describe("ViewComponent", () => {
|
||||
const showToast = jest.fn();
|
||||
const showPasswordPrompt = jest.fn().mockResolvedValue(true);
|
||||
const getFeatureFlag$ = jest.fn().mockReturnValue(of(true));
|
||||
const getFeatureFlag = jest.fn().mockResolvedValue(true);
|
||||
const currentAutofillTab$ = of({ url: "https://example.com", id: 1 });
|
||||
|
||||
const mockCipher = {
|
||||
id: "122-333-444",
|
||||
@@ -87,8 +96,12 @@ describe("ViewComponent", () => {
|
||||
const mockPasswordRepromptService = {
|
||||
showPasswordPrompt,
|
||||
};
|
||||
const autofillAllowed$ = new BehaviorSubject<boolean>(true);
|
||||
const mockVaultPopupAutofillService = {
|
||||
doAutofill,
|
||||
doAutofillAndSave,
|
||||
currentAutofillTab$,
|
||||
autofillAllowed$,
|
||||
};
|
||||
const mockCopyCipherFieldService = {
|
||||
copy,
|
||||
@@ -112,12 +125,15 @@ describe("ViewComponent", () => {
|
||||
mockNavigate.mockClear();
|
||||
collect.mockClear();
|
||||
doAutofill.mockClear();
|
||||
doAutofillAndSave.mockClear();
|
||||
copy.mockClear();
|
||||
stop.mockClear();
|
||||
openSimpleDialog.mockClear();
|
||||
back.mockClear();
|
||||
showToast.mockClear();
|
||||
showPasswordPrompt.mockClear();
|
||||
getFeatureFlag.mockClear();
|
||||
autofillAllowed$.next(true);
|
||||
cipherArchiveService.hasArchiveFlagEnabled$ = of(true);
|
||||
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData);
|
||||
@@ -137,7 +153,7 @@ describe("ViewComponent", () => {
|
||||
{ provide: VaultPopupScrollPositionService, useValue: { stop } },
|
||||
{ provide: VaultPopupAutofillService, useValue: mockVaultPopupAutofillService },
|
||||
{ provide: ToastService, useValue: { showToast } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag$ } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag$, getFeatureFlag } },
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
@@ -203,6 +219,8 @@ describe("ViewComponent", () => {
|
||||
provide: DomainSettingsService,
|
||||
useValue: {
|
||||
showFavicons$: of(true),
|
||||
resolvedDefaultUriMatchStrategy$: of(UriMatchStrategy.Domain),
|
||||
getUrlEquivalentDomains: jest.fn().mockReturnValue(of([])),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -697,4 +715,452 @@ describe("ViewComponent", () => {
|
||||
expect(badge).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showAutofillButton", () => {
|
||||
beforeEach(() => {
|
||||
component.cipher = { ...mockCipher, type: CipherType.Login } as CipherView;
|
||||
});
|
||||
|
||||
it("returns true when feature flag is enabled, cipher is a login, and not archived/deleted", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Login,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(true);
|
||||
}));
|
||||
|
||||
it("returns true for Card type when conditions are met", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Card,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(true);
|
||||
}));
|
||||
|
||||
it("returns true for Identity type when conditions are met", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Identity,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(true);
|
||||
}));
|
||||
|
||||
it("returns false when feature flag is disabled", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
// Recreate component to pick up the new feature flag value
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Login,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false when autofill is not allowed", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(false);
|
||||
|
||||
// Recreate component to pick up the new autofillAllowed value
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Login,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false for SecureNote type", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.SecureNote,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false for SshKey type", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.SshKey,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false when cipher is archived", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Login,
|
||||
isArchived: true,
|
||||
isDeleted: false,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false when cipher is deleted", fakeAsync(() => {
|
||||
getFeatureFlag$.mockReturnValue(of(true));
|
||||
autofillAllowed$.next(true);
|
||||
|
||||
// Recreate component to pick up the signal values
|
||||
fixture = TestBed.createComponent(ViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Login,
|
||||
isArchived: false,
|
||||
isDeleted: true,
|
||||
} as CipherView;
|
||||
|
||||
flush();
|
||||
|
||||
const result = component.showAutofillButton();
|
||||
|
||||
expect(result).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("doAutofill", () => {
|
||||
let dialogService: DialogService;
|
||||
const originalCurrentAutofillTab$ = currentAutofillTab$;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogService = TestBed.inject(DialogService);
|
||||
|
||||
component.cipher = {
|
||||
...mockCipher,
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test",
|
||||
password: "test",
|
||||
uris: [
|
||||
{
|
||||
uri: "https://example.com",
|
||||
match: null,
|
||||
} as LoginUriView,
|
||||
],
|
||||
},
|
||||
edit: true,
|
||||
} as CipherView;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original observable to prevent test pollution
|
||||
mockVaultPopupAutofillService.currentAutofillTab$ = originalCurrentAutofillTab$;
|
||||
});
|
||||
|
||||
it("returns early when feature flag is disabled", async () => {
|
||||
getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(doAutofill).not.toHaveBeenCalled();
|
||||
expect(openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows exact match dialog when no URIs and default strategy is Exact", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
component.cipher.login.uris = [];
|
||||
(component as any).uriMatchStrategy$ = of(UriMatchStrategy.Exact);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "cannotAutofill" },
|
||||
content: { key: "cannotAutofillExactMatch" },
|
||||
type: "info",
|
||||
acceptButtonText: { key: "okay" },
|
||||
cancelButtonText: null,
|
||||
});
|
||||
expect(doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows exact match dialog when all URIs have exact match strategy", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
component.cipher.login.uris = [
|
||||
{ uri: "https://example.com", match: UriMatchStrategy.Exact } as LoginUriView,
|
||||
{ uri: "https://example2.com", match: UriMatchStrategy.Exact } as LoginUriView,
|
||||
];
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "cannotAutofill" },
|
||||
content: { key: "cannotAutofillExactMatch" },
|
||||
type: "info",
|
||||
acceptButtonText: { key: "okay" },
|
||||
cancelButtonText: null,
|
||||
});
|
||||
expect(doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error dialog when current tab URL is unavailable", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
mockVaultPopupAutofillService.currentAutofillTab$ = of({ url: null, id: 1 });
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "error" },
|
||||
content: { key: "errorGettingAutoFillData" },
|
||||
type: "danger",
|
||||
});
|
||||
expect(doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autofills directly when domain matches", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(true);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(doAutofill).toHaveBeenCalledWith(component.cipher, true, true);
|
||||
});
|
||||
|
||||
it("shows confirmation dialog when domain does not match", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.Canceled),
|
||||
};
|
||||
jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(AutofillConfirmationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
|
||||
data: {
|
||||
currentUrl: "https://example.com",
|
||||
savedUrls: ["https://example.com"],
|
||||
viewOnly: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not autofill when user cancels confirmation dialog", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.Canceled),
|
||||
};
|
||||
jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(doAutofill).not.toHaveBeenCalled();
|
||||
expect(doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autofills only when user selects AutofilledOnly", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.AutofilledOnly),
|
||||
};
|
||||
jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(doAutofill).toHaveBeenCalledWith(component.cipher, true, true);
|
||||
expect(doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autofills and saves URL when user selects AutofillAndUrlAdded", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.AutofillAndUrlAdded),
|
||||
};
|
||||
jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(doAutofillAndSave).toHaveBeenCalledWith(component.cipher, true, true);
|
||||
expect(doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes viewOnly as true when cipher is not editable", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
component.cipher.edit = false;
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.Canceled),
|
||||
};
|
||||
const openSpy = jest
|
||||
.spyOn(AutofillConfirmationDialogComponent, "open")
|
||||
.mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(dialogService, {
|
||||
data: {
|
||||
currentUrl: "https://example.com",
|
||||
savedUrls: ["https://example.com"],
|
||||
viewOnly: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out URIs without uri property", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
component.cipher.login.uris = [
|
||||
{ uri: "https://example.com" } as LoginUriView,
|
||||
{ uri: null } as LoginUriView,
|
||||
{ uri: "https://example2.com" } as LoginUriView,
|
||||
];
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.Canceled),
|
||||
};
|
||||
const openSpy = jest
|
||||
.spyOn(AutofillConfirmationDialogComponent, "open")
|
||||
.mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(dialogService, {
|
||||
data: {
|
||||
currentUrl: "https://example.com",
|
||||
savedUrls: ["https://example.com", "https://example2.com"],
|
||||
viewOnly: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles cipher with no login uris gracefully", async () => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false);
|
||||
component.cipher.login.uris = null;
|
||||
|
||||
const mockDialogRef = {
|
||||
closed: of(AutofillConfirmationDialogResult.Canceled),
|
||||
};
|
||||
const openSpy = jest
|
||||
.spyOn(AutofillConfirmationDialogComponent, "open")
|
||||
.mockReturnValue(mockDialogRef as any);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(dialogService, {
|
||||
data: {
|
||||
currentUrl: "https://example.com",
|
||||
savedUrls: [],
|
||||
viewOnly: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, Observable, switchMap, of, map } from "rxjs";
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
SHOW_AUTOFILL_BUTTON,
|
||||
UPDATE_PASSWORD,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -32,6 +36,7 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
@@ -66,6 +71,10 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil
|
||||
import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service";
|
||||
import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window";
|
||||
import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit.component";
|
||||
import {
|
||||
AutofillConfirmationDialogComponent,
|
||||
AutofillConfirmationDialogResult,
|
||||
} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component";
|
||||
|
||||
/**
|
||||
* The types of actions that can be triggered when loading the view vault item popout via the
|
||||
@@ -118,6 +127,13 @@ export class ViewComponent {
|
||||
senderTabId?: number;
|
||||
routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION;
|
||||
|
||||
//feature flag
|
||||
private readonly pm30521FeatureFlag = toSignal(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM30521_AutofillButtonViewLoginScreen),
|
||||
);
|
||||
|
||||
private readonly autofillAllowed = toSignal(this.vaultPopupAutofillService.autofillAllowed$);
|
||||
private uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$;
|
||||
protected showFooter$: Observable<boolean>;
|
||||
protected userCanArchive$ = this.accountService.activeAccount$
|
||||
.pipe(getUserId)
|
||||
@@ -142,6 +158,8 @@ export class ViewComponent {
|
||||
private popupScrollPositionService: VaultPopupScrollPositionService,
|
||||
private archiveService: CipherArchiveService,
|
||||
private archiveCipherUtilsService: ArchiveCipherUtilitiesService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
@@ -322,6 +340,113 @@ export class ViewComponent {
|
||||
: this.cipherService.softDeleteWithServer(this.cipher.id, this.activeUserId);
|
||||
}
|
||||
|
||||
showAutofillButton(): boolean {
|
||||
//feature flag
|
||||
if (!this.pm30521FeatureFlag()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.autofillAllowed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const validAutofillType = (
|
||||
[CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[]
|
||||
).includes(CipherViewLikeUtils.getType(this.cipher));
|
||||
|
||||
return validAutofillType && !(this.cipher.isArchived || this.cipher.isDeleted);
|
||||
}
|
||||
|
||||
async doAutofill() {
|
||||
//feature flag
|
||||
if (
|
||||
!(await this.configService.getFeatureFlag(FeatureFlag.PM30521_AutofillButtonViewLoginScreen))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
//for non login types that are still auto-fillable
|
||||
if (CipherViewLikeUtils.getType(this.cipher) !== CipherType.Login) {
|
||||
await this.vaultPopupAutofillService.doAutofill(this.cipher, true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const uris = this.cipher.login?.uris ?? [];
|
||||
const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$);
|
||||
|
||||
const showExactMatchDialog =
|
||||
uris.length === 0
|
||||
? uriMatchStrategy === UriMatchStrategy.Exact
|
||||
: // all saved URIs are exact match
|
||||
uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact);
|
||||
|
||||
if (showExactMatchDialog) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "cannotAutofill" },
|
||||
content: { key: "cannotAutofillExactMatch" },
|
||||
type: "info",
|
||||
acceptButtonText: { key: "okay" },
|
||||
cancelButtonText: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//this tab checking should be moved into the vault-popup-autofill service in case the current tab is changed
|
||||
//ticket: https://bitwarden.atlassian.net/browse/PM-32467
|
||||
const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$);
|
||||
|
||||
if (!currentTab?.url) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "error" },
|
||||
content: { key: "errorGettingAutoFillData" },
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this._domainMatched(currentTab)) {
|
||||
await this.vaultPopupAutofillService.doAutofill(this.cipher, true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = AutofillConfirmationDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
currentUrl: currentTab?.url || "",
|
||||
savedUrls: this.cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [],
|
||||
viewOnly: !this.cipher.edit,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(ref.closed);
|
||||
|
||||
switch (result) {
|
||||
case AutofillConfirmationDialogResult.Canceled:
|
||||
return;
|
||||
case AutofillConfirmationDialogResult.AutofilledOnly:
|
||||
await this.vaultPopupAutofillService.doAutofill(this.cipher, true, true);
|
||||
return;
|
||||
case AutofillConfirmationDialogResult.AutofillAndUrlAdded:
|
||||
await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher, true, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async _domainMatched(currentTab: chrome.tabs.Tab): Promise<boolean> {
|
||||
const equivalentDomains = await firstValueFrom(
|
||||
this.domainSettingsService.getUrlEquivalentDomains(currentTab?.url),
|
||||
);
|
||||
const defaultMatch = await firstValueFrom(
|
||||
this.domainSettingsService.resolvedDefaultUriMatchStrategy$,
|
||||
);
|
||||
|
||||
return CipherViewLikeUtils.matchesUri(
|
||||
this.cipher,
|
||||
currentTab?.url,
|
||||
equivalentDomains,
|
||||
defaultMatch,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the load action for the view vault item popout. These actions are typically triggered
|
||||
* via the extension context menu. It is necessary to render the view for items that have password
|
||||
|
||||
@@ -70,6 +70,7 @@ export enum FeatureFlag {
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
|
||||
PM30521_AutofillButtonViewLoginScreen = "pm-30521-autofill-button-view-login-screen",
|
||||
PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt",
|
||||
PM29438_DialogWithExtensionPromptAccountAge = "pm-29438-dialog-with-extension-prompt-account-age",
|
||||
PM29437_WelcomeDialog = "pm-29437-welcome-dialog-no-ext-prompt",
|
||||
@@ -139,6 +140,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
[FeatureFlag.PM30521_AutofillButtonViewLoginScreen]: FALSE,
|
||||
[FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt]: FALSE,
|
||||
[FeatureFlag.PM29438_DialogWithExtensionPromptAccountAge]: 5,
|
||||
[FeatureFlag.PM29437_WelcomeDialog]: FALSE,
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
[folder]="folder()"
|
||||
[hideOwner]="isAdminConsole()"
|
||||
>
|
||||
<ng-content select="[slot=button]"></ng-content>
|
||||
</app-item-details-v2>
|
||||
|
||||
<!-- LOGIN CREDENTIALS -->
|
||||
|
||||
@@ -89,5 +89,6 @@
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-content></ng-content>
|
||||
</bit-card>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user