mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-19 02:51:14 +02:00
[PM-8486] Browser Refresh - Autofill functionality (#9616)
* [PM-8486] Introduce VaultPopupAutofill service * [PM-8486] Remove moved autofill functionality from VaultPopupItem service * [PM-8486] Add autofill functionality to button and menu options * [PM-8486] Hide Autofill and Save option for Cards/Identities * [PM-8486] Reduce nesting in closePopup * [PM-8486] Breakup doAutofillAndSave method * [PM-8486] Start subscription in autofill service constructor * [PM-8486] Cleanup missed calls to removed methods
This commit is contained in:
parent
2042b3a26c
commit
b7a961bf1f
@ -12,6 +12,7 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
|
||||
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
|
||||
import { PopupCipherView } from "../../../views/popup-cipher.view";
|
||||
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
|
||||
@ -53,7 +54,7 @@ export class AutofillVaultListItemsComponent {
|
||||
protected showEmptyAutofillTip$: Observable<boolean> = combineLatest([
|
||||
this.vaultPopupItemsService.hasFilterApplied$,
|
||||
this.autofillCiphers$,
|
||||
this.vaultPopupItemsService.autofillAllowed$,
|
||||
this.vaultPopupAutofillService.autofillAllowed$,
|
||||
]).pipe(
|
||||
map(
|
||||
([hasFilter, ciphers, canAutoFill]) =>
|
||||
@ -61,7 +62,10 @@ export class AutofillVaultListItemsComponent {
|
||||
),
|
||||
);
|
||||
|
||||
constructor(private vaultPopupItemsService: VaultPopupItemsService) {
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupAutofillService: VaultPopupAutofillService,
|
||||
) {
|
||||
// TODO: Migrate logic to show Autofill policy toast PM-8144
|
||||
}
|
||||
|
||||
@ -70,6 +74,6 @@ export class AutofillVaultListItemsComponent {
|
||||
* @protected
|
||||
*/
|
||||
protected refreshCurrentTab() {
|
||||
this.vaultPopupItemsService.refreshCurrentTab();
|
||||
this.vaultPopupAutofillService.refreshCurrentTab();
|
||||
}
|
||||
}
|
||||
|
@ -10,10 +10,10 @@
|
||||
<bit-menu #moreOptions>
|
||||
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
|
||||
<ng-container *ngIf="autofillAllowed$ | async">
|
||||
<button type="button" bitMenuItem>
|
||||
<button type="button" bitMenuItem (click)="doAutofill()">
|
||||
{{ "autofill" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem *ngIf="canEdit">
|
||||
<button type="button" bitMenuItem *ngIf="canEdit && isLogin" (click)="doAutofillAndSave()">
|
||||
{{ "fillAndSave" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
@ -18,7 +18,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
|
||||
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@ -39,16 +39,16 @@ export class ItemMoreOptionsComponent {
|
||||
@Input({ transform: booleanAttribute })
|
||||
hideAutofillOptions: boolean;
|
||||
|
||||
protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$;
|
||||
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
private toastService: ToastService,
|
||||
private dialogService: DialogService,
|
||||
private router: Router,
|
||||
private i18nService: I18nService,
|
||||
private vaultPopupAutofillService: VaultPopupAutofillService,
|
||||
) {}
|
||||
|
||||
get canEdit() {
|
||||
@ -62,10 +62,22 @@ export class ItemMoreOptionsComponent {
|
||||
return [CipherType.Login, CipherType.Card, CipherType.Identity].includes(this.cipher.type);
|
||||
}
|
||||
|
||||
get isLogin() {
|
||||
return this.cipher.type === CipherType.Login;
|
||||
}
|
||||
|
||||
get favoriteText() {
|
||||
return this.cipher.favorite ? "unfavorite" : "favorite";
|
||||
}
|
||||
|
||||
async doAutofill() {
|
||||
await this.vaultPopupAutofillService.doAutofill(this.cipher);
|
||||
}
|
||||
|
||||
async doAutofillAndSave() {
|
||||
await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the login cipher can be launched in a new browser tab.
|
||||
*/
|
||||
|
@ -40,6 +40,7 @@
|
||||
type="button"
|
||||
bitBadge
|
||||
variant="primary"
|
||||
(click)="doAutofill(cipher)"
|
||||
[title]="'autofillTitle' | i18n: cipher.name"
|
||||
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
|
||||
>
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
|
||||
import { PopupCipherView } from "../../../views/popup-cipher.view";
|
||||
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
|
||||
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
|
||||
@ -87,5 +88,12 @@ export class VaultListItemsContainerComponent {
|
||||
return cipher.collections[0]?.name;
|
||||
}
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private vaultPopupAutofillService: VaultPopupAutofillService,
|
||||
) {}
|
||||
|
||||
async doAutofill(cipher: PopupCipherView) {
|
||||
await this.vaultPopupAutofillService.doAutofill(cipher);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router, RouterLink } from "@angular/router";
|
||||
import { RouterLink } from "@angular/router";
|
||||
import { combineLatest } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@ -59,10 +59,7 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
|
||||
protected VaultStateEnum = VaultState;
|
||||
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private router: Router,
|
||||
) {
|
||||
constructor(private vaultPopupItemsService: VaultPopupItemsService) {
|
||||
combineLatest([
|
||||
this.vaultPopupItemsService.emptyVault$,
|
||||
this.vaultPopupItemsService.noFilteredResults$,
|
||||
|
@ -0,0 +1,356 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { subscribeTo } from "@bitwarden/common/spec";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import {
|
||||
AutoFillOptions,
|
||||
AutofillService,
|
||||
PageDetail,
|
||||
} from "../../../autofill/services/abstractions/autofill.service";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
|
||||
import { VaultPopupAutofillService } from "./vault-popup-autofill.service";
|
||||
|
||||
describe("VaultPopupAutofillService", () => {
|
||||
let testBed: TestBed;
|
||||
let service: VaultPopupAutofillService;
|
||||
|
||||
const mockCurrentTab = { url: "https://example.com" } as chrome.tabs.Tab;
|
||||
|
||||
// Create mocks for VaultPopupAutofillService
|
||||
const mockAutofillService = mock<AutofillService>();
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockToastService = mock<ToastService>();
|
||||
const mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
const mockPasswordRepromptService = mock<PasswordRepromptService>();
|
||||
const mockCipherService = mock<CipherService>();
|
||||
const mockMessagingService = mock<MessagingService>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(mockCurrentTab);
|
||||
|
||||
mockAutofillService.collectPageDetailsFromTab$.mockReturnValue(new BehaviorSubject([]));
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AutofillService, useValue: mockAutofillService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
|
||||
{ provide: CipherService, useValue: mockCipherService },
|
||||
{ provide: MessagingService, useValue: mockMessagingService },
|
||||
],
|
||||
});
|
||||
|
||||
service = testBed.inject(VaultPopupAutofillService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("currentAutofillTab$", () => {
|
||||
it("should return null if in popout", (done) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
service.refreshCurrentTab();
|
||||
service.currentAutofillTab$.subscribe((tab) => {
|
||||
expect(tab).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return BrowserApi.getTabFromCurrentWindow() if not in popout", (done) => {
|
||||
service.currentAutofillTab$.subscribe((tab) => {
|
||||
expect(tab).toEqual(mockCurrentTab);
|
||||
expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should only fetch the current tab once when subscribed to multiple times", async () => {
|
||||
const firstTracked = subscribeTo(service.currentAutofillTab$);
|
||||
const secondTracked = subscribeTo(service.currentAutofillTab$);
|
||||
|
||||
await firstTracked.pauseUntilReceived(1);
|
||||
await secondTracked.pauseUntilReceived(1);
|
||||
|
||||
expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autofillAllowed$", () => {
|
||||
it("should return true if there is a current tab", (done) => {
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if there is no current tab", (done) => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
service.refreshCurrentTab();
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if in a popout", (done) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
service.refreshCurrentTab();
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshCurrentTab()", () => {
|
||||
it("should refresh currentAutofillTab$", async () => {
|
||||
const tracked = subscribeTo(service.currentAutofillTab$);
|
||||
service.refreshCurrentTab();
|
||||
await tracked.pauseUntilReceived(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autofill methods", () => {
|
||||
const mockPageDetails: PageDetail[] = [{ tab: mockCurrentTab, details: {} as any, frameId: 1 }];
|
||||
let mockCipher: CipherView;
|
||||
let expectedAutofillArgs: AutoFillOptions;
|
||||
let mockPageDetails$: BehaviorSubject<PageDetail[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCipher = new CipherView();
|
||||
mockCipher.type = CipherType.Login;
|
||||
|
||||
mockPageDetails$ = new BehaviorSubject(mockPageDetails);
|
||||
|
||||
mockAutofillService.collectPageDetailsFromTab$.mockReturnValue(mockPageDetails$);
|
||||
|
||||
expectedAutofillArgs = {
|
||||
tab: mockCurrentTab,
|
||||
cipher: mockCipher,
|
||||
pageDetails: mockPageDetails,
|
||||
doc: expect.any(Document),
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
};
|
||||
|
||||
// Refresh the current tab so the mockedPageDetails$ are used
|
||||
service.refreshCurrentTab();
|
||||
});
|
||||
|
||||
describe("doAutofill()", () => {
|
||||
it("should return true if autofill is successful", async () => {
|
||||
mockAutofillService.doAutoFill.mockResolvedValue(null);
|
||||
const result = await service.doAutofill(mockCipher);
|
||||
expect(result).toBe(true);
|
||||
expect(mockAutofillService.doAutoFill).toHaveBeenCalledWith(expectedAutofillArgs);
|
||||
});
|
||||
|
||||
it("should return false if autofill is not successful", async () => {
|
||||
mockAutofillService.doAutoFill.mockRejectedValue(null);
|
||||
const result = await service.doAutofill(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: mockI18nService.t("autofillError"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if tab is null", async () => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
const result = await service.doAutofill(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: mockI18nService.t("autofillError"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if missing page details", async () => {
|
||||
mockPageDetails$.next([]);
|
||||
const result = await service.doAutofill(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: mockI18nService.t("autofillError"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should show password prompt if cipher requires reprompt", async () => {
|
||||
mockCipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
|
||||
const result = await service.doAutofill(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should copy TOTP code to clipboard if available", async () => {
|
||||
const totpCode = "123456";
|
||||
mockAutofillService.doAutoFill.mockResolvedValue(totpCode);
|
||||
await service.doAutofill(mockCipher);
|
||||
expect(mockPlatformUtilsService.copyToClipboard).toHaveBeenCalledWith(
|
||||
totpCode,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
describe("closePopup", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(BrowserApi, "closePopup").mockImplementation();
|
||||
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
|
||||
mockPlatformUtilsService.isFirefox.mockReturnValue(true);
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should close popup by default when in popup", async () => {
|
||||
await service.doAutofill(mockCipher);
|
||||
expect(BrowserApi.closePopup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not close popup when closePopup is set to false", async () => {
|
||||
await service.doAutofill(mockCipher, false);
|
||||
expect(BrowserApi.closePopup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should close popup after a timeout for chromium browsers", async () => {
|
||||
mockPlatformUtilsService.isFirefox.mockReturnValue(false);
|
||||
jest.spyOn(global, "setTimeout");
|
||||
await service.doAutofill(mockCipher);
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(setTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(BrowserApi.closePopup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("doAutofillAndSave()", () => {
|
||||
beforeEach(() => {
|
||||
// Mocks for service._closePopup()
|
||||
jest.spyOn(BrowserApi, "closePopup").mockImplementation();
|
||||
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
|
||||
mockPlatformUtilsService.isFirefox.mockReturnValue(true);
|
||||
|
||||
// Default to happy path
|
||||
mockAutofillService.doAutoFill.mockResolvedValue(null);
|
||||
mockCipherService.updateWithServer.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("should return false if cipher is not login type", async () => {
|
||||
mockCipher.type = CipherType.Card;
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
expect(mockAutofillService.doAutoFill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false if autofill is not successful", async () => {
|
||||
mockAutofillService.doAutoFill.mockRejectedValue(null);
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: mockI18nService.t("autofillError"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return true if the cipher already has a URI for the tab", async () => {
|
||||
mockCipher.login = new LoginView();
|
||||
mockCipher.login.uris = [{ uri: mockCurrentTab.url } as LoginUriView];
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(true);
|
||||
expect(BrowserApi.closePopup).toHaveBeenCalled();
|
||||
expect(mockCipherService.updateWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show a success toast if closePopup is false and cipher already has URI for tab", async () => {
|
||||
mockCipher.login = new LoginView();
|
||||
mockCipher.login.uris = [{ uri: mockCurrentTab.url } as LoginUriView];
|
||||
const result = await service.doAutofillAndSave(mockCipher, false);
|
||||
expect(result).toBe(true);
|
||||
expect(BrowserApi.closePopup).not.toHaveBeenCalled();
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: mockI18nService.t("autoFillSuccessAndSavedUri"),
|
||||
});
|
||||
expect(mockCipherService.updateWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should add a URI to the cipher and save with the server", async () => {
|
||||
const mockEncryptedCipher = {} as Cipher;
|
||||
mockCipherService.encrypt.mockResolvedValue(mockEncryptedCipher);
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(true);
|
||||
expect(mockCipher.login.uris).toHaveLength(1);
|
||||
expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url);
|
||||
expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher);
|
||||
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher);
|
||||
});
|
||||
|
||||
it("should add a URI to the cipher when there are no existing URIs", async () => {
|
||||
mockCipher.login.uris = null;
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(true);
|
||||
expect(mockCipher.login.uris).toHaveLength(1);
|
||||
expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url);
|
||||
});
|
||||
|
||||
it("should show an error toast if saving the cipher fails", async () => {
|
||||
mockCipherService.updateWithServer.mockRejectedValue(null);
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: mockI18nService.t("unexpectedError"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should close the popup after saving the cipher", async () => {
|
||||
const result = await service.doAutofillAndSave(mockCipher);
|
||||
expect(result).toBe(true);
|
||||
expect(BrowserApi.closePopup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show success toast after saving the cipher if closePop is false", async () => {
|
||||
mockAutofillService.doAutoFill.mockResolvedValue(null);
|
||||
const result = await service.doAutofillAndSave(mockCipher, false);
|
||||
expect(result).toBe(true);
|
||||
expect(BrowserApi.closePopup).not.toHaveBeenCalled();
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: mockI18nService.t("autoFillSuccessAndSavedUri"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,237 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import {
|
||||
AutofillService,
|
||||
PageDetail,
|
||||
} from "../../../autofill/services/abstractions/autofill.service";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class VaultPopupAutofillService {
|
||||
private _refreshCurrentTab$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Observable that contains the current tab to be considered for autofill. If there is no current tab
|
||||
* or the popup is in a popout window, this will be null.
|
||||
*/
|
||||
currentAutofillTab$: Observable<chrome.tabs.Tab | null> = this._refreshCurrentTab$.pipe(
|
||||
startWith(null),
|
||||
switchMap(async () => {
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
return null;
|
||||
}
|
||||
return await BrowserApi.getTabFromCurrentWindow();
|
||||
}),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that indicates whether autofill is allowed in the current context.
|
||||
* Autofill is allowed when there is a current tab and the popup is not in a popout window.
|
||||
*/
|
||||
autofillAllowed$: Observable<boolean> = this.currentAutofillTab$.pipe(map((tab) => !!tab));
|
||||
|
||||
private _currentPageDetails$: Observable<PageDetail[]> = this.currentAutofillTab$.pipe(
|
||||
switchMap((tab) => {
|
||||
if (!tab) {
|
||||
return of([]);
|
||||
}
|
||||
return this.autofillService.collectPageDetailsFromTab$(tab);
|
||||
}),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private autofillService: AutofillService,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private platformUtilService: PlatformUtilsService,
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
private cipherService: CipherService,
|
||||
private messagingService: MessagingService,
|
||||
) {
|
||||
this._currentPageDetails$.subscribe();
|
||||
}
|
||||
|
||||
private async _internalDoAutofill(
|
||||
cipher: CipherView,
|
||||
tab: chrome.tabs.Tab,
|
||||
pageDetails: PageDetail[],
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
cipher.reprompt !== CipherRepromptType.None &&
|
||||
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tab == null || pageDetails.length === 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("autofillError"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const totpCode = await this.autofillService.doAutoFill({
|
||||
tab,
|
||||
cipher,
|
||||
pageDetails,
|
||||
doc: window.document,
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
});
|
||||
|
||||
if (totpCode != null) {
|
||||
this.platformUtilService.copyToClipboard(totpCode, { window: window });
|
||||
}
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("autofillError"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _closePopup() {
|
||||
if (!BrowserPopupUtils.inPopup(window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.platformUtilService.isFirefox() || this.platformUtilService.isSafari()) {
|
||||
BrowserApi.closePopup(window);
|
||||
return;
|
||||
}
|
||||
|
||||
// Slight delay to fix bug in Chromium browsers where popup closes without copying totp to clipboard
|
||||
setTimeout(() => BrowserApi.closePopup(window), 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the current tab
|
||||
*/
|
||||
refreshCurrentTab() {
|
||||
this._refreshCurrentTab$.next(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to autofill the given cipher. Returns true if the autofill was successful, false otherwise.
|
||||
* Will copy any TOTP code to the clipboard if available after successful autofill.
|
||||
* @param cipher
|
||||
* @param closePopup If true, will close the popup window after successful autofill. Defaults to true.
|
||||
*/
|
||||
async doAutofill(cipher: CipherView, closePopup = true): Promise<boolean> {
|
||||
const tab = await firstValueFrom(this.currentAutofillTab$);
|
||||
const pageDetails = await firstValueFrom(this._currentPageDetails$);
|
||||
|
||||
const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails);
|
||||
|
||||
if (didAutofill && closePopup) {
|
||||
this._closePopup();
|
||||
}
|
||||
|
||||
return didAutofill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to autofill the given cipher and, upon successful autofill, saves the URI to the cipher.
|
||||
* Will copy any TOTP code to the clipboard if available after successful autofill.
|
||||
* @param cipher The cipher to autofill and save. Only Login ciphers are supported.
|
||||
* @param closePopup If true, will close the popup window after successful autofill.
|
||||
* If false, will show a success toast instead. Defaults to true.
|
||||
*/
|
||||
async doAutofillAndSave(cipher: CipherView, closePopup = true): Promise<boolean> {
|
||||
// We can only save URIs for login ciphers
|
||||
if (cipher.type !== CipherType.Login) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageDetails = await firstValueFrom(this._currentPageDetails$);
|
||||
const tab = await firstValueFrom(this.currentAutofillTab$);
|
||||
|
||||
const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails);
|
||||
|
||||
if (!didAutofill) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const didSaveUri = await this._saveNewUri(cipher, tab);
|
||||
|
||||
if (!didSaveUri) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (closePopup) {
|
||||
this._closePopup();
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("autoFillSuccessAndSavedUri"),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current tab's URL as a new URI for the given cipher. If the cipher already has a URI for the tab,
|
||||
* this method does nothing and returns true.
|
||||
* @private
|
||||
*/
|
||||
private async _saveNewUri(cipher: CipherView, tab: chrome.tabs.Tab): Promise<boolean> {
|
||||
cipher.login.uris ??= [];
|
||||
|
||||
if (cipher.login.uris.some((uri) => uri.uri === tab.url)) {
|
||||
// Cipher already has a URI for this tab
|
||||
return true;
|
||||
}
|
||||
|
||||
const loginUri = new LoginUriView();
|
||||
loginUri.uri = tab.url;
|
||||
cipher.login.uris.push(loginUri);
|
||||
|
||||
try {
|
||||
const encCipher = await this.cipherService.encrypt(cipher);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
this.messagingService.send("editedCipher");
|
||||
return true;
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -16,8 +16,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
|
||||
import { VaultPopupAutofillService } from "./vault-popup-autofill.service";
|
||||
import { VaultPopupItemsService } from "./vault-popup-items.service";
|
||||
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
|
||||
|
||||
@ -36,6 +36,7 @@ describe("VaultPopupItemsService", () => {
|
||||
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
|
||||
const searchService = mock<SearchService>();
|
||||
const collectionService = mock<CollectionService>();
|
||||
const vaultAutofillServiceMock = mock<VaultPopupAutofillService>();
|
||||
|
||||
beforeEach(() => {
|
||||
allCiphers = cipherFactory(10);
|
||||
@ -70,10 +71,10 @@ describe("VaultPopupItemsService", () => {
|
||||
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
|
||||
(ciphers: CipherView[]) => ciphers,
|
||||
);
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(BrowserApi, "getTabFromCurrentWindow")
|
||||
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
|
||||
|
||||
vaultAutofillServiceMock.currentAutofillTab$ = new BehaviorSubject({
|
||||
url: "https://example.com",
|
||||
} as chrome.tabs.Tab);
|
||||
|
||||
mockOrg = {
|
||||
id: "org1",
|
||||
@ -97,6 +98,7 @@ describe("VaultPopupItemsService", () => {
|
||||
{ provide: OrganizationService, useValue: organizationServiceMock },
|
||||
{ provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock },
|
||||
{ provide: CollectionService, useValue: collectionService },
|
||||
{ provide: VaultPopupAutofillService, useValue: vaultAutofillServiceMock },
|
||||
],
|
||||
});
|
||||
|
||||
@ -155,15 +157,7 @@ describe("VaultPopupItemsService", () => {
|
||||
|
||||
describe("autoFillCiphers$", () => {
|
||||
it("should return empty array if there is no current tab", (done) => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers).toEqual([]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array if in Popout window", (done) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
(vaultAutofillServiceMock.currentAutofillTab$ as BehaviorSubject<any>).next(null);
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers).toEqual([]);
|
||||
done();
|
||||
@ -319,31 +313,6 @@ describe("VaultPopupItemsService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoFillAllowed$", () => {
|
||||
it("should return true if there is a current tab", (done) => {
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if there is no current tab", (done) => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if in a Popout", (done) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
service.autofillAllowed$.subscribe((allowed) => {
|
||||
expect(allowed).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("noFilteredResults$", () => {
|
||||
it("should return false when filteredResults has values", (done) => {
|
||||
service.noFilteredResults$.subscribe((noResults) => {
|
||||
|
@ -28,11 +28,10 @@ import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { PopupCipherView } from "../views/popup-cipher.view";
|
||||
|
||||
import { VaultPopupAutofillService } from "./vault-popup-autofill.service";
|
||||
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
|
||||
|
||||
/**
|
||||
@ -42,7 +41,6 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi
|
||||
providedIn: "root",
|
||||
})
|
||||
export class VaultPopupItemsService {
|
||||
private _refreshCurrentTab$ = new Subject<void>();
|
||||
private _searchText$ = new BehaviorSubject<string>("");
|
||||
|
||||
/**
|
||||
@ -70,22 +68,6 @@ export class VaultPopupItemsService {
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that contains the current tab to be considered for autofill. If there is no current tab
|
||||
* or the popup is in a popout window, this will be null.
|
||||
* @private
|
||||
*/
|
||||
private _currentAutofillTab$: Observable<chrome.tabs.Tab | null> = this._refreshCurrentTab$.pipe(
|
||||
startWith(null),
|
||||
switchMap(async () => {
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
return null;
|
||||
}
|
||||
return await BrowserApi.getTabFromCurrentWindow();
|
||||
}),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that contains the list of all decrypted ciphers.
|
||||
* @private
|
||||
@ -145,7 +127,7 @@ export class VaultPopupItemsService {
|
||||
autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([
|
||||
this._filteredCipherList$,
|
||||
this._otherAutoFillTypes$,
|
||||
this._currentAutofillTab$,
|
||||
this.vaultPopupAutofillService.currentAutofillTab$,
|
||||
]).pipe(
|
||||
switchMap(([ciphers, otherTypes, tab]) => {
|
||||
if (!tab) {
|
||||
@ -217,12 +199,6 @@ export class VaultPopupItemsService {
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that indicates whether autofill is allowed in the current context.
|
||||
* Autofill is allowed when there is a current tab and the popup is not in a popout window.
|
||||
*/
|
||||
autofillAllowed$: Observable<boolean> = this._currentAutofillTab$.pipe(map((tab) => !!tab));
|
||||
|
||||
/**
|
||||
* Observable that indicates whether the user's vault is empty.
|
||||
*/
|
||||
@ -257,15 +233,9 @@ export class VaultPopupItemsService {
|
||||
private organizationService: OrganizationService,
|
||||
private searchService: SearchService,
|
||||
private collectionService: CollectionService,
|
||||
private vaultPopupAutofillService: VaultPopupAutofillService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Re-fetch the current tab to trigger a re-evaluation of the autofill ciphers.
|
||||
*/
|
||||
refreshCurrentTab() {
|
||||
this._refreshCurrentTab$.next(null);
|
||||
}
|
||||
|
||||
applyFilter(newSearchText: string) {
|
||||
this._searchText$.next(newSearchText);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user