1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-09 09:51:02 +01: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:
Shane Melton 2024-06-27 13:30:55 -07:00 committed by GitHub
parent 2042b3a26c
commit b7a961bf1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 640 additions and 86 deletions

View File

@ -12,6 +12,7 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; 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 { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { PopupCipherView } from "../../../views/popup-cipher.view"; import { PopupCipherView } from "../../../views/popup-cipher.view";
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
@ -53,7 +54,7 @@ export class AutofillVaultListItemsComponent {
protected showEmptyAutofillTip$: Observable<boolean> = combineLatest([ protected showEmptyAutofillTip$: Observable<boolean> = combineLatest([
this.vaultPopupItemsService.hasFilterApplied$, this.vaultPopupItemsService.hasFilterApplied$,
this.autofillCiphers$, this.autofillCiphers$,
this.vaultPopupItemsService.autofillAllowed$, this.vaultPopupAutofillService.autofillAllowed$,
]).pipe( ]).pipe(
map( map(
([hasFilter, ciphers, canAutoFill]) => ([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 // TODO: Migrate logic to show Autofill policy toast PM-8144
} }
@ -70,6 +74,6 @@ export class AutofillVaultListItemsComponent {
* @protected * @protected
*/ */
protected refreshCurrentTab() { protected refreshCurrentTab() {
this.vaultPopupItemsService.refreshCurrentTab(); this.vaultPopupAutofillService.refreshCurrentTab();
} }
} }

View File

@ -10,10 +10,10 @@
<bit-menu #moreOptions> <bit-menu #moreOptions>
<ng-container *ngIf="canAutofill && !hideAutofillOptions"> <ng-container *ngIf="canAutofill && !hideAutofillOptions">
<ng-container *ngIf="autofillAllowed$ | async"> <ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem> <button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }} {{ "autofill" | i18n }}
</button> </button>
<button type="button" bitMenuItem *ngIf="canEdit"> <button type="button" bitMenuItem *ngIf="canEdit && isLogin" (click)="doAutofillAndSave()">
{{ "fillAndSave" | i18n }} {{ "fillAndSave" | i18n }}
</button> </button>
</ng-container> </ng-container>

View File

@ -18,7 +18,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api"; import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; 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({ @Component({
standalone: true, standalone: true,
@ -39,16 +39,16 @@ export class ItemMoreOptionsComponent {
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
hideAutofillOptions: boolean; hideAutofillOptions: boolean;
protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
constructor( constructor(
private cipherService: CipherService, private cipherService: CipherService,
private vaultPopupItemsService: VaultPopupItemsService,
private passwordRepromptService: PasswordRepromptService, private passwordRepromptService: PasswordRepromptService,
private toastService: ToastService, private toastService: ToastService,
private dialogService: DialogService, private dialogService: DialogService,
private router: Router, private router: Router,
private i18nService: I18nService, private i18nService: I18nService,
private vaultPopupAutofillService: VaultPopupAutofillService,
) {} ) {}
get canEdit() { get canEdit() {
@ -62,10 +62,22 @@ export class ItemMoreOptionsComponent {
return [CipherType.Login, CipherType.Card, CipherType.Identity].includes(this.cipher.type); return [CipherType.Login, CipherType.Card, CipherType.Identity].includes(this.cipher.type);
} }
get isLogin() {
return this.cipher.type === CipherType.Login;
}
get favoriteText() { get favoriteText() {
return this.cipher.favorite ? "unfavorite" : "favorite"; 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. * Determines if the login cipher can be launched in a new browser tab.
*/ */

View File

@ -40,6 +40,7 @@
type="button" type="button"
bitBadge bitBadge
variant="primary" variant="primary"
(click)="doAutofill(cipher)"
[title]="'autofillTitle' | i18n: cipher.name" [title]="'autofillTitle' | i18n: cipher.name"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name" [attr.aria-label]="'autofillTitle' | i18n: cipher.name"
> >

View File

@ -14,6 +14,7 @@ import {
TypographyModule, TypographyModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { PopupCipherView } from "../../../views/popup-cipher.view"; import { PopupCipherView } from "../../../views/popup-cipher.view";
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
@ -87,5 +88,12 @@ export class VaultListItemsContainerComponent {
return cipher.collections[0]?.name; 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);
}
} }

View File

@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterLink } from "@angular/router"; import { RouterLink } from "@angular/router";
import { combineLatest } from "rxjs"; import { combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -59,10 +59,7 @@ export class VaultV2Component implements OnInit, OnDestroy {
protected VaultStateEnum = VaultState; protected VaultStateEnum = VaultState;
constructor( constructor(private vaultPopupItemsService: VaultPopupItemsService) {
private vaultPopupItemsService: VaultPopupItemsService,
private router: Router,
) {
combineLatest([ combineLatest([
this.vaultPopupItemsService.emptyVault$, this.vaultPopupItemsService.emptyVault$,
this.vaultPopupItemsService.noFilteredResults$, this.vaultPopupItemsService.noFilteredResults$,

View File

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

View File

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

View File

@ -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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { BrowserApi } from "../../../platform/browser/browser-api"; 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 { VaultPopupItemsService } from "./vault-popup-items.service";
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
@ -36,6 +36,7 @@ describe("VaultPopupItemsService", () => {
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>(); const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
const searchService = mock<SearchService>(); const searchService = mock<SearchService>();
const collectionService = mock<CollectionService>(); const collectionService = mock<CollectionService>();
const vaultAutofillServiceMock = mock<VaultPopupAutofillService>();
beforeEach(() => { beforeEach(() => {
allCiphers = cipherFactory(10); allCiphers = cipherFactory(10);
@ -70,10 +71,10 @@ describe("VaultPopupItemsService", () => {
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers, (ciphers: CipherView[]) => ciphers,
); );
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest vaultAutofillServiceMock.currentAutofillTab$ = new BehaviorSubject({
.spyOn(BrowserApi, "getTabFromCurrentWindow") url: "https://example.com",
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); } as chrome.tabs.Tab);
mockOrg = { mockOrg = {
id: "org1", id: "org1",
@ -97,6 +98,7 @@ describe("VaultPopupItemsService", () => {
{ provide: OrganizationService, useValue: organizationServiceMock }, { provide: OrganizationService, useValue: organizationServiceMock },
{ provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock }, { provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock },
{ provide: CollectionService, useValue: collectionService }, { provide: CollectionService, useValue: collectionService },
{ provide: VaultPopupAutofillService, useValue: vaultAutofillServiceMock },
], ],
}); });
@ -155,15 +157,7 @@ describe("VaultPopupItemsService", () => {
describe("autoFillCiphers$", () => { describe("autoFillCiphers$", () => {
it("should return empty array if there is no current tab", (done) => { it("should return empty array if there is no current tab", (done) => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); (vaultAutofillServiceMock.currentAutofillTab$ as BehaviorSubject<any>).next(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);
service.autoFillCiphers$.subscribe((ciphers) => { service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers).toEqual([]); expect(ciphers).toEqual([]);
done(); 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$", () => { describe("noFilteredResults$", () => {
it("should return false when filteredResults has values", (done) => { it("should return false when filteredResults has values", (done) => {
service.noFilteredResults$.subscribe((noResults) => { service.noFilteredResults$.subscribe((noResults) => {

View File

@ -28,11 +28,10 @@ import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; 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 { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { PopupCipherView } from "../views/popup-cipher.view"; 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"; 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", providedIn: "root",
}) })
export class VaultPopupItemsService { export class VaultPopupItemsService {
private _refreshCurrentTab$ = new Subject<void>();
private _searchText$ = new BehaviorSubject<string>(""); 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. * Observable that contains the list of all decrypted ciphers.
* @private * @private
@ -145,7 +127,7 @@ export class VaultPopupItemsService {
autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([ autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([
this._filteredCipherList$, this._filteredCipherList$,
this._otherAutoFillTypes$, this._otherAutoFillTypes$,
this._currentAutofillTab$, this.vaultPopupAutofillService.currentAutofillTab$,
]).pipe( ]).pipe(
switchMap(([ciphers, otherTypes, tab]) => { switchMap(([ciphers, otherTypes, tab]) => {
if (!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. * Observable that indicates whether the user's vault is empty.
*/ */
@ -257,15 +233,9 @@ export class VaultPopupItemsService {
private organizationService: OrganizationService, private organizationService: OrganizationService,
private searchService: SearchService, private searchService: SearchService,
private collectionService: CollectionService, 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) { applyFilter(newSearchText: string) {
this._searchText$.next(newSearchText); this._searchText$.next(newSearchText);
} }