1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

[PM-4229] Autofill Overlay MVP (#6507)

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Adding enums for the browser popout type

* [PM-3914] Making the methods for getting a window in a targeted manner public

* [PM-3914] Refactoing implementation

* [PM-3914] Updating deprecated api call

* [PM-3914] Fixing issues found when testing behavior

* [PM-3914] Reimplementing behavior based on feedback from platform team

* [PM-3914] Adding method of ensuring previously opened single action window is force closed for vault item password reprompts

* [PM-3914] Taking into consideration feedback regarding the browser popup utils service and implementating requested changes

* [PM-3914] Removing unnecesssary class dependencies

* [PM-3914] Adding method for uniquely setting up password reprompt windows

* [PM-3914] Modifying method

* [PM-3914] Adding jest tests and documentation for AuthPopoutWindow util

* [PM-3914] Adding jest tests and documentation for VaultPopoutWindow

* [PM-3914] Adding jest tests for the debouncing method within autofill service

* [PM-3914] Adding jest tests for the new BrowserApi methods

* [PM-3914] Adding jest tests to the BrowserPopupUtils class

* [PM-3914] Updating inPrivateMode reference

* [PM-3914] Updating inPrivateMode reference

* [PM-3914] Modifying comment

* [PM-3914] Moviing implementation for openCurrentPagePopout to the BrowserPopupUtils

* [PM-3914] Applying feedback

* [PM-3914] Applying feedback

* [PM-3914] Applying feedback

* [PM-3983] Refactoring implementation of `setContentScrollY` to facilitate having a potential delay

* [PM-3914] Applying feedback regarding setContentScrollY to the implementation

* [PM-3914] Modifying early return within the run method of the ContextMenuClickedHandler

* [PM-3914] Adding test for VaultPopoutWindow

* [PM-4229] Autofill Overlay MVP

* [PM-2855] Add Settings to Enable Autofill Overlay (#6509)

* [PM-2855] Add Settings to Enable Autofill Overlay

* [PM-2855] Removing unnecessary key

* [PM-3914] Applying work done within PM-4366 to facilitate opening the popout window as a popup rather than a normal window

* [PM-3914] Updating the BrowserApi.removeTab method to leverage a callback structure for the promise rather than an async away structure

* [PM-3036] Adding jest tests for added passkeys popout windows

* [PM-3914] Adjsuting logic for turning off the warning when FIDO2 credentials are saved

* [PM-3914] Fixing height to design

* [PM-3914] Fixing call to Fido2 Popout

* [PM-3914] Fixing add/edit from fido2 popout

* [PM-3914] Fixing add/edit from fido2 popout

* [PM-3914] Fixing jest tests for updated elements

* [PM-3914] Reverting how context menu actions are passed to the view component

* [PM-3914] Reverting re-instantiation of config service within main.background.ts

* [PM-3914] Adding jest test for BrowserAPI removeTab method

* [PM-3914] Adding method to handle parsing the popout url path

* [PM-3914] Removing JSDOC comment elements

* [PM-3914] Removing await from method call

* [PM-3914] Simplifying implementation on add/edit

* [PM-3032] Adding more direct reference to view item action in context menus

* [PM-3034] Modify Autofill Callout to Consider Autofill Overlay Visibility (#6510)

* [PM-2855] Add Settings to Enable Autofill Overlay

* [PM-2855] Removing unnecessary key

* [PM-3034] Modify Autofill Callout to Consider Autofill Overlay Visibility

* [PM-3034] Adding translated strings

* [PM-3034] Updating boolean logic for showing the callout to remove unnecessary negation of boolean statement

* [PM-3914] Adjusting routing on Fido2 component to pass the singleActionPopout param to the route when opening the add-edit component

* [PM-3914] Adding singleActionPopout param to the fido2 component routing

* [PM-3914] Updating implementation details for how we build the extension url path

* [PM-3914] Reworking implementation for isSingleActionPopoutOpen to clean up iterative logic

* [PM-3914] Merging work from master and fixing merge conflicts

* [PM-3914] Fixing merge conflict introduced from master

* [PM-3914] Reworking closure of single action popouts to ensure they close the window instead of attempting to close the tab

* [PM-3036] Implement Autofill Overlay Unlock State (#6514)

* [PM-2855] Add Settings to Enable Autofill Overlay

* [PM-2855] Removing unnecessary key

* [PM-3034] Modify Autofill Callout to Consider Autofill Overlay Visibility

* [PM-3034] Adding translated strings

* [PM-3034] Add Autofill Overlay Vault Locked State

* [PM-3036] Bootstrap Autofill Overlay implementation and add locked vault state

* [PM-3032] Removing add/edit cipher message

* [PM-3036] Fixing lint error found within overlay background

* [PM-3036] Setting properties within the autofill component method to be protected

* [PM-3034] Updating boolean logic for showing the callout to remove unnecessary negation of boolean statement

* [PM-3036] Applying feedback from browser popout refactor PR

* [PM-3036] Adding ownership over the website icon service file to the autofill team

* [PM-3036] Updating the `autoFillOverlayVisibility` setting to be a client-scoped setting rather than account-scoped

* [PM-3036] Reworking jest setup implementation to facilitate approach recommended within code review

* [PM-3036] Updating WebsiteIconService to act as a single function reference and moving it to be under the vault team as codeowners

* [PM-3032] Show Matching Logins When User Interacts with Field (#6516)

* [PM-3032] Show Matching Logins When User Interacts with Field

* [PM-3032] Fixing issue found when changing pages

* [PM-3032] Addressing feedback within PR

* [PM-3032] Addressing feedback within PR

* [PM-3033] Allow User to Fill Matching Logins within Overlay (#6517)

* [PM-3033] Allow User to Fill Matching Logins within Overlay

* [PM-3035] Allow adding new items when no ciphers found in overlay (#6518)

* [PM-2319] Refactoring implementation to leverage styles within the encapsulated custom elements rather than inline on those elements

* [PM-2319] Leveraging globalThis to avoid potential DOM clobbering within implementation

* [PM-2319] Fixing issue where styles can override visibility of overlay icon and list

* [PM-2319] Fixing issue where styles can override visibility of overlay icon and list

* [PM-2319] Implementing more secure method for ensuring overlay is visible

* [PM-2319] Optimizing implementation of mutation observers on elements that need to enforce CSS styling

* [PM-2319] Refactoring how we handle mutation observers to allow for a more streamlined implementation approach

* [PM-2319] Implementing view cipher item initial workflow

* [PM-2319] Implementing obfruscation of username within login ciphers

* [PM-2747] Fixing logic error incorporated when merging in master

* [PM-2130] Fixing issue with autofill service unit tests

* [PM-2130] Fixing issue with autofill service unit tests

* [PM-2747] Fixing issue present with notification bar merge

* [PM-2130] Fixing test test for when we need to handle a password reprompt

* [PM-2319] Fixing issue present with context menu handler

* [PM-2319] Implementing fixes for password reprompt when autofilling from overlay

* [PM-2319] Working through accessibility and focus order on overlay elements

* [PM-2319] Finishing out focus redirection approach for focus out of overlay list

* [PM-2319] Working through screen reader accessibility including aria attributes

* [PM-2319] Adding guard to usage of extension privacy api

* [PM-2319] Adding guard to usage of extension privacy api

* [PM-2319] Adding aria description for fill cipher elements

* [PM-2319] Refactoring implementation

* [PM-2319] Working through implementation of view cipher tirggers when overlay set to view an element

* [PM-2319] Refining implementation for viewing vault item from overlay

* [PM-2319] Applying fix for context menu ciphers

* [PM-2319] Modifying namespace for overlay icon to overlay button

* [PM-2319] Refactoring OverlayButton

* [PM-2319] Refactoring OverlayButton

* [PM-2319] Adding translations for overlay content

* [PM-2319] Refactoring OverlayBackground class

* [PM-2319] Refactoring OverlayBackground class to more optimially store and retrieve cipher data for the overlay elements

* [PM-2319] Refactoring OverlayBackground class

* [PM-2319] Refactoring AutofillOverlayList class structure

* [PM-2319] Implementing randomization of custom element names for elements injected into tab

* [PM-2319] Updating how we handle referencing port messages within the OverlayIframe service

* [PM-3465] Optimization of CollectPageDetails Message within Autofill

* [PM-3465] Implementing caching for CollectPage details call

* [PM-3465] Implementing caching for CollectPage details call

* [PM-3465] Implementing method for ensuring that getPageDetails is not called when no fields appear within a frame

* [PM-3465] Implementing Mutation Observer to handle updating autofill fields when DOM updates

* [PM-2747] Fixing wording for webpack script

* [PM-2130] - Audit, Modularize, and Refactor Core autofill.js File (#5453)

* split up autofill.ts, first pass

* remove modification tracking comments

* lessen and localize eslint disables

* additional typing and formatting

* update autofill v2 with PR #5364 changes (update/i18n confirm dialogs)

* update autofill v2 with PR #4155 changes (add autofill support for textarea)

Co-Authored-By: Manuel <mr-manuel@outlook.it>

* move commonly used string values to constants

* ts cleanup

* [PM-2130] Starting work to re-architect autofillv2.ts

* [PM-2130] Starting work to re-architect autofillv2.ts

* [PM-2130] Working through autofill collect method

* [PM-2130] Marking Removal of documentUUID as dead code

* [PM-2130] Refining the implementation of collect and moving broken out utils back into class implementation

* [PM-2130] Applying small refactors to AutofillCollect

* [PM-2130] Refining the implementation of getAutofillFieldLabelTag to help with readability of the method

* [PM-2130] Implementing jest tests for AutofillCollect methods

* [PM-2130] Refining implementation for AutofillCollect

* [PM-2200] Unit tests for autofill content script utilities with slight refactors (#5544)

* add unit tests for urlNotSecure

* add test coverage command

* add unit tests for canSeeElementToStyle

* canSeeElementToStyle should not return true if `animateTheFilling` or `currentEl` is false

* add tests for selectAllFromDoc and getElementByOpId

* clean up getElementByOpId

* address some typing issues

* add tests for setValueForElementByEvent, setValueForElement, and doSimpleSetByQuery

* clean up setValueForElement and setValueForElementByEvent

* more typescript cleanup

* add tests for doClickByOpId and touchAllPasswordFields

* add tests for doFocusByOpId and doClickByQuery

* misc fill cleanup

* move functions between collect and fill utils and replace getElementForOPID for duplicate getElementByOpId

* add tests for isKnownTag and isElementVisible

* rename addProp and remove redundant focusElement in favor of doFocusElement

* cleanup

* fix checkNodeType

* add tests for shiftForLeftLabel

* clean up and rename checkNodeType, isKnownTag, and shiftForLeftLabel

* add tests for getFormElements

* clean up getFormElements

* add tests for getElementAttrValue, getElementValue, getSelectElementOptions, getLabelTop, and queryDoc

* clean up and rename queryDoc to queryDocument

* misc cleanup and rename getElementAttrValue to getPropertyOrAttribute

* rebase cleanup

* prettier formatting

* [PM-2130] Fixing linting issues

* [PM-2130] Fixing linting issues

* [PM-2130] Migrating implementation for collect methods and tests for those methods into AutofillCollect context

* [PM-2130] Migrating getPropertyOrAttribute method from utils to AutofillCollect

* [PM-2130] Continuing migration of methods from collect utils into AutofillCollect

* [PM-2130] Rework of isViewable method to better handle behavior for how we identify if an element is currently within the viewport

* [PM-2130] Filling out implementation of autofill-insert

* [PM-2130] Refining AutofillInsert

* [PM-2130] Implementing jest tests for AutofillCollect methods and breaking out visibility related logic to a separate service

* [PM-2130] Fixing jest tests for AutofillCollect

* [PM-2130] Fixing jest tests for AutofillInit

* [PM-2130] Adjusting how the AutofillFieldVisibilityService class is used in AutofillCollect

* [PM-2130] Working through AutofillInsert implementation

* [PM-2130] Migrating methods from fill.ts to AutofillInsert

* [PM-2130] Migrating methods from fill.ts to AutofillInsert

* [PM-2130] Applying fix for IntersectionObserver when triggering behavior in Safari and fixing issue with how we trigger an input event shortly after filling in a field

* [PM-2130] Refactoring AutofillCollect to service CollectAutofillContentService

* [PM-2130] Refactoring AutofillInsert to service InsertAutofillContentService

* [PM-2130] Further organization of implementation

* [PM-2130] Filling out missing jest test for AutofillInit.fillForm method

* [PM-2130] Migrating the last of the collect jest tests to InsertAutofillContentService

* [PM-2130] Further refactoring of elements including typing information

* [PM-2130] Implementing jest tests for InsertAutofillContentService

* [PM-2130] Implementing jest tests for InsertAutofillContentService

* [PM-2130] Organization and refactoring of methods within InsertAutofillContent

* [PM-2130] Implementation of jest tests for InsertAutofillContentService

* [PM-2130] Implementation of Jest Test for IntertAutofillContentService

* [PM-2130] Finalizing migration of methods and jest tests from util files into Autofill serivces

* [PM-2130] Cleaning up dead code comments

* [PM-2130] Removing unnecessary constants

* [PM-2130] Finalizing jest tests for InsertAutofillContentService

* [PM-2130] Refactoring FieldVisibiltyService to DomElementVisibilityService to allow service to act in a more general manner

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Breaking out the callback method used to resolve the IntersectionObserver promise

* [PM-2130] Adding a comment explaining a fix for Safari

* [PM-2130] Adding a comment explaining a fix for Safari

* [PM-2130] Applying changes required for PM-2762 to implementation, and ensuring jest tests exist to validate the behavior

* [PM-2130] Removing usage of IntersectionObserver when identifying element visibility due to broken interactions with React Components

* [PM-2130] Fixing issue found when attempting to capture the elementAtCenterPoint in determining file visibility

* [PM-2100] Create Unit Test Suite for autofill.service.ts (#5371)

* [PM-2100] Create Unit Test Suite for Autofill.service.ts

* [PM-2100] Finishing out tests for the getFormsWithPasswordFields method

* [PM-2100] Implementing tests for the doAutofill method within the autofill service

* [PM-2100] Working through implementation of doAutofill method

* [PM-2100] Working through implementation of doAutofill method

* [PM-2100] Finishing implementatino of isUntrustedIframe method within autofill service

* [PM-2100] Finishing implementation of doAutoFill method within autofill service

* [PM-2100] Finishing implementation of doAutoFillOnTab method within autofill service

* [PM-2100] Working through tests for generateFillScript

* [PM-2100] Finalizing generateFillScript method testing

* [PM-2100] Starting implementation of generateLoginFillScript

* [PM-2100] Working through tests for generateLoginFillScript

* [PM-2100] Finalizing generateLoginFillScript method testing

* [PM-2100] Removing unnecessary jest config file

* [PM-2100] Fixing jest tests based on changes implemented within PM-2130

* [PM-2100] Fixing autofill mocks

* [PM-2100] Fixing AutofillService jest tests

* [PM-2100] Handling missing tests within coverage of AutofillService

* [PM-2100] Handling missing tests within coverage of AutofillService.generateLoginFillScript

* [PM-2100] Writing tests for AutofillService.generateCardFillScript

* [PM-2100] Finalizing tests for AutofillService.generateCardFillScript

* [PM-2100] Adding additional tests to cover changes introduced by TOTOP autofill PR

* [PM-2100] Adding jest tests for Autofill.generateIdentityFillScript

* [PM-2100] Finalizing tests for AutofillService.generateIdentityFillScript

* [PM-2100] Implementing tests for AutofillService

* [PM-2100] Implementing tests for AutofillService.loadPasswordFields

* [PM-2100] Implementing tests for AutofillService.findUsernameField

* [PM-2100] Implementing tests for AutofillService.findTotpField

* [PM-2100] Implementing tests for AutofillService.fieldPropertyIsPrefixMatch

* [PM-2100] Finalizing tests for AutofillService

* [PM-2100] Modyfing placement of autofill-mocks

* [PM-2100] Modyfing placement of autofill-mocks

* [PM-2100] Removal of jest transform declaration

* [PM-2130] Fixing issue with autofill service unit tests

* [PM-2130] Fixing issue with autofill service unit tests

* [PM-2130] Fixing test test for when we need to handle a password reprompt

---------

Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Cesar Gonzalez <cgonzalez@bitwarden.com>
Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* [PM-2747] Finanlizing implementation of attribute updates on cached values

* [PM-2319] Refactoring implementation to reposition OverlayIframe classes

* [PM-3465] Finalizing implementation of mutation observer behavior and CollectPageDetails optimization

* [PM-3465] Adding jest tests for introduced functionality

* [PM-3465] Finalizing jest tests and comments within implementation

* [PM-3465] Removing a TODO by incorrporating a method for deep querying for a password field element

* [PM-3465] Removing a TODO by incorrporating a method for deep querying for a password field element

* [PM-3285] Migrating Changes from PM-1407 into autofill v2 refactor implementation

* [PM-2747] Addressing stylistic changes requested from code review

* [PM-2319] Refactoring implementation

* [PM-2747] Add Support for Feature Flag of Autofill Version (#5695)

* [PM-2100] Create Unit Test Suite for Autofill.service.ts

* [PM-2100] Finishing out tests for the getFormsWithPasswordFields method

* [PM-2100] Implementing tests for the doAutofill method within the autofill service

* [PM-2100] Working through implementation of doAutofill method

* [PM-2100] Working through implementation of doAutofill method

* [PM-2100] Finishing implementatino of isUntrustedIframe method within autofill service

* [PM-2100] Finishing implementation of doAutoFill method within autofill service

* [PM-2100] Finishing implementation of doAutoFillOnTab method within autofill service

* [PM-2100] Working through tests for generateFillScript

* split up autofill.ts, first pass

* remove modification tracking comments

* lessen and localize eslint disables

* additional typing and formatting

* update autofill v2 with PR #5364 changes (update/i18n confirm dialogs)

* update autofill v2 with PR #4155 changes (add autofill support for textarea)

Co-Authored-By: Manuel <mr-manuel@outlook.it>

* move commonly used string values to constants

* ts cleanup

* [PM-2100] Finalizing generateFillScript method testing

* [PM-2100] Starting implementation of generateLoginFillScript

* [PM-2100] Working through tests for generateLoginFillScript

* [PM-2100] Finalizing generateLoginFillScript method testing

* [PM-2130] Starting work to re-architect autofillv2.ts

* [PM-2130] Starting work to re-architect autofillv2.ts

* [PM-2130] Working through autofill collect method

* [PM-2130] Marking Removal of documentUUID as dead code

* [PM-2130] Refining the implementation of collect and moving broken out utils back into class implementation

* [PM-2130] Applying small refactors to AutofillCollect

* [PM-2130] Refining the implementation of getAutofillFieldLabelTag to help with readability of the method

* [PM-2130] Implementing jest tests for AutofillCollect methods

* [PM-2130] Refining implementation for AutofillCollect

* [PM-2200] Unit tests for autofill content script utilities with slight refactors (#5544)

* add unit tests for urlNotSecure

* add test coverage command

* add unit tests for canSeeElementToStyle

* canSeeElementToStyle should not return true if `animateTheFilling` or `currentEl` is false

* add tests for selectAllFromDoc and getElementByOpId

* clean up getElementByOpId

* address some typing issues

* add tests for setValueForElementByEvent, setValueForElement, and doSimpleSetByQuery

* clean up setValueForElement and setValueForElementByEvent

* more typescript cleanup

* add tests for doClickByOpId and touchAllPasswordFields

* add tests for doFocusByOpId and doClickByQuery

* misc fill cleanup

* move functions between collect and fill utils and replace getElementForOPID for duplicate getElementByOpId

* add tests for isKnownTag and isElementVisible

* rename addProp and remove redundant focusElement in favor of doFocusElement

* cleanup

* fix checkNodeType

* add tests for shiftForLeftLabel

* clean up and rename checkNodeType, isKnownTag, and shiftForLeftLabel

* add tests for getFormElements

* clean up getFormElements

* add tests for getElementAttrValue, getElementValue, getSelectElementOptions, getLabelTop, and queryDoc

* clean up and rename queryDoc to queryDocument

* misc cleanup and rename getElementAttrValue to getPropertyOrAttribute

* rebase cleanup

* prettier formatting

* [PM-2130] Fixing linting issues

* [PM-2130] Fixing linting issues

* [PM-2130] Migrating implementation for collect methods and tests for those methods into AutofillCollect context

* [PM-2130] Migrating getPropertyOrAttribute method from utils to AutofillCollect

* [PM-2130] Continuing migration of methods from collect utils into AutofillCollect

* [PM-2130] Rework of isViewable method to better handle behavior for how we identify if an element is currently within the viewport

* [PM-2130] Filling out implementation of autofill-insert

* [PM-2130] Refining AutofillInsert

* [PM-2130] Implementing jest tests for AutofillCollect methods and breaking out visibility related logic to a separate service

* [PM-2130] Fixing jest tests for AutofillCollect

* [PM-2130] Fixing jest tests for AutofillInit

* [PM-2130] Adjusting how the AutofillFieldVisibilityService class is used in AutofillCollect

* [PM-2130] Working through AutofillInsert implementation

* [PM-2130] Migrating methods from fill.ts to AutofillInsert

* [PM-2130] Migrating methods from fill.ts to AutofillInsert

* [PM-2130] Applying fix for IntersectionObserver when triggering behavior in Safari and fixing issue with how we trigger an input event shortly after filling in a field

* [PM-2130] Refactoring AutofillCollect to service CollectAutofillContentService

* [PM-2130] Refactoring AutofillInsert to service InsertAutofillContentService

* [PM-2130] Further organization of implementation

* [PM-2130] Filling out missing jest test for AutofillInit.fillForm method

* [PM-2130] Migrating the last of the collect jest tests to InsertAutofillContentService

* [PM-2130] Further refactoring of elements including typing information

* [PM-2130] Implementing jest tests for InsertAutofillContentService

* [PM-2130] Implementing jest tests for InsertAutofillContentService

* [PM-2130] Organization and refactoring of methods within InsertAutofillContent

* [PM-2130] Implementation of jest tests for InsertAutofillContentService

* [PM-2130] Implementation of Jest Test for IntertAutofillContentService

* [PM-2130] Finalizing migration of methods and jest tests from util files into Autofill serivces

* [PM-2130] Cleaning up dead code comments

* [PM-2130] Removing unnecessary constants

* [PM-2130] Finalizing jest tests for InsertAutofillContentService

* [PM-2130] Refactoring FieldVisibiltyService to DomElementVisibilityService to allow service to act in a more general manner

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Implementing jest tests for DomElementVisibilityService

* [PM-2130] Breaking out the callback method used to resolve the IntersectionObserver promise

* [PM-2100] Removing unnecessary jest config file

* [PM-2100] Fixing jest tests based on changes implemented within PM-2130

* [PM-2100] Fixing autofill mocks

* [PM-2100] Fixing AutofillService jest tests

* [PM-2100] Handling missing tests within coverage of AutofillService

* [PM-2100] Handling missing tests within coverage of AutofillService.generateLoginFillScript

* [PM-2100] Writing tests for AutofillService.generateCardFillScript

* [PM-2100] Finalizing tests for AutofillService.generateCardFillScript

* [PM-2100] Adding additional tests to cover changes introduced by TOTOP autofill PR

* [PM-2100] Adding jest tests for Autofill.generateIdentityFillScript

* [PM-2100] Finalizing tests for AutofillService.generateIdentityFillScript

* [PM-2100] Implementing tests for AutofillService

* [PM-2130] Adding a comment explaining a fix for Safari

* [PM-2130] Adding a comment explaining a fix for Safari

* [PM-2100] Implementing tests for AutofillService.loadPasswordFields

* [PM-2100] Implementing tests for AutofillService.findUsernameField

* [PM-2100] Implementing tests for AutofillService.findTotpField

* [PM-2100] Implementing tests for AutofillService.fieldPropertyIsPrefixMatch

* [PM-2100] Finalizing tests for AutofillService

* [PM-2747] Add Support for Feature Flag of Autofill Version

* [PM-2747] Adding Support for Manifest v3 within the implementation

* [PM-2747] Modifying how the feature flag for autofill is named

* [PM-2747] Modifying main.background.ts to load the ConfigApiService correctly

* [PM-2747] Refactoring trigger of autofill scripts to be a simple immediately invoked function

* [PM-2100] Modyfing placement of autofill-mocks

* [PM-2100] Modyfing placement of autofill-mocks

* [PM-2100] Removal of jest transform declaration

* [PM-2130] Applying changes required for PM-2762 to implementation, and ensuring jest tests exist to validate the behavior

* [PM-2747] Modifying how we inject the autofill scripts to ensure we are injecting into all frames within a page

* [PM-2130] Removing usage of IntersectionObserver when identifying element visibility due to broken interactions with React Components

* [PM-2130] Fixing issue found when attempting to capture the elementAtCenterPoint in determining file visibility

* [PM-2100] Create Unit Test Suite for autofill.service.ts (#5371)

* [PM-2100] Create Unit Test Suite for Autofill.service.ts

* [PM-2100] Finishing out tests for the getFormsWithPasswordFields method

* [PM-2100] Implementing tests for the doAutofill method within the autofill service

* [PM-2100] Working through implementation of doAutofill method

* [PM-2100] Working through implementation of doAutofill method

* [PM-2100] Finishing implementatino of isUntrustedIframe method within autofill service

* [PM-2100] Finishing implementation of doAutoFill method within autofill service

* [PM-2100] Finishing implementation of doAutoFillOnTab method within autofill service

* [PM-2100] Working through tests for generateFillScript

* [PM-2100] Finalizing generateFillScript method testing

* [PM-2100] Starting implementation of generateLoginFillScript

* [PM-2100] Working through tests for generateLoginFillScript

* [PM-2100] Finalizing generateLoginFillScript method testing

* [PM-2100] Removing unnecessary jest config file

* [PM-2100] Fixing jest tests based on changes implemented within PM-2130

* [PM-2100] Fixing autofill mocks

* [PM-2100] Fixing AutofillService jest tests

* [PM-2100] Handling missing tests within coverage of AutofillService

* [PM-2100] Handling missing tests within coverage of AutofillService.generateLoginFillScript

* [PM-2100] Writing tests for AutofillService.generateCardFillScript

* [PM-2100] Finalizing tests for AutofillService.generateCardFillScript

* [PM-2100] Adding additional tests to cover changes introduced by TOTOP autofill PR

* [PM-2100] Adding jest tests for Autofill.generateIdentityFillScript

* [PM-2100] Finalizing tests for AutofillService.generateIdentityFillScript

* [PM-2100] Implementing tests for AutofillService

* [PM-2100] Implementing tests for AutofillService.loadPasswordFields

* [PM-2100] Implementing tests for AutofillService.findUsernameField

* [PM-2100] Implementing tests for AutofillService.findTotpField

* [PM-2100] Implementing tests for AutofillService.fieldPropertyIsPrefixMatch

* [PM-2100] Finalizing tests for AutofillService

* [PM-2100] Modyfing placement of autofill-mocks

* [PM-2100] Modyfing placement of autofill-mocks

* [PM-2100] Removal of jest transform declaration

* [PM-2747] Applying a fix for a race condition that can occur when loading the notification bar and autofiller script login

* [PM-2747] Reverting removal of autofill npm action. Now this will force usage of autofill-v2 regardless of whether a feature flag is set or not

* [PM-2747] Fixing logic error incorporated when merging in master

* [PM-2130] Fixing issue with autofill service unit tests

* [PM-2130] Fixing issue with autofill service unit tests

* [PM-2747] Fixing issue present with notification bar merge

* [PM-2130] Fixing test test for when we need to handle a password reprompt

* [PM-2747] Fixing wording for webpack script

* [PM-2747] Addressing stylistic changes requested from code review

* [PM-2747] Addressing stylistic changes requested from code review

---------

Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>

* [PM-3285] Applying stylistic changes suggested by code review for the feature flag implementation

* [PM-3285] Adding temporary console log to validate which version is being used

* [PM-2319] Adjusting translation content

* [PM-3465] Implementing a methodology for sorting the autofill field elements after awaiting the results of each element

* [PM-3465] Implementing a methodology for sorting the autofill field elements after awaiting the results of each element

* [PM-3465] Implementing a methodology for using cached field values when requerying DOM for elements

* [PM-2319] Adjusting translation content

* [PM-2319] Adding typing information for OverlayBackground

* [PM-2319] Removing unnecesssary methods within OverlayBackground and AutofillOverlayContentService

* [PM-2319] Refactoring implementation and incorpoarting BrowserApi class more effectively

* [PM-2319] Fixing issue found with opening overaly element during reprompt of vault item

* [PM-2319] Fixing issue found with auth status not updating when overlay is initializing

* [PM-2319] Implementing a method for initializing the overlay with the user auth status

* [PM-2319] Fixing issue where shadowRoot elements might not initialize overlay on setup

* [PM-2319] Implementing await for runFillScriptAction

* [PM-2319] Implementing methodology for having list of elements hide after user starts inputting within field

* [PM-2319] Removing unnecesssary methods within OverlayBackground and AutofillOverlayContentService

* [PM-2319] Fixing tab focus issue

* [PM-2319] Fixing issue where page details would unload sooner than desired

* [PM-2319] Fixing tab focus issues present on page details

* [PM-2319] Adjusting how we iterate over cipher data

* [PM-2319] Refactoring overlay background

* [PM-2319] Adding typing information for OverlayBackground

* [PM-2319] Adding typing information for OverlayBackground

* [PM-2319] Refactoring and optimizing for loops

* [PM-2319] Refactoring and optimizing how we listen for overlay element ports

* [PM-2319] Implementing method for ensuring overlay removes itself if user scrolls focused input element out of viewport

* [PM-2319] Replacing usage of foreach for a regular for loop

* [PM-2319] Replacing usage of foreach for a regular for loop

* [PM-2319] Refactoring forEach loops within CollectAutofillContent and moving autofill utils to a top level

* [PM-2319] Refactoring getRandomCustomElementName util method

* [PM-2319] Refactoring implementation

* [PM-2319] Refactoring implementation

* [PM-2319] Replacing hardcoded values for events with constant enum

* [PM-2319] Adding reduced animation declaration for fill

* [PM-2319] Adjusting implementation of mutation observer to better handle insertion of elements around overlay

* [PM-2319] Fixing jest test

* [PM-2319] Implementing method for ensuring tab focus from the overlay button can move to the correct place

* [PM-2319] Refactoring implementation

* [PM-3285] Removing temporary console log indicating which version of autofill the user is currently loading

* [PM-3465] Adding scripting api reference to the manifest v3 json file

* [PM-2319] Splitting shared logic within the overlay page implementations to act as a parent class for the overlay button and list pages

* [PM-2319] Updating file names for page scripts

* [PM-2319] Updating file names for page scripts

* [PM-2319] Fixing issues present with overlay background when updating auth status

* [PM-2319] Refactoring implementation

* [PM-2319] Fixing cache invalidation issues present with the collect page details optimization

* [PM-3465] Updating implementation to deal with cache invalidation issues

* [PM-3465] Implementing jest tests for added collect autofill content class elements

* [PM-3465] Removing scripting API permissiong within manifest v3 json file

* [PM-2319] Adding scripting api to manifest v3

* [PM-2319] Fixing issue present with non visible fields having an overlay element

* [PM-3465] Implementing method for removing cached page details if the window location has updated

* [PM-3465] Fixing issue found with query selector generated while collecting page details

* [PM-2319] Commenting out code that overrides default browser autofill behavior in chrome

* [PM-3465] Fixing jest tests

* [PM-3465] Fixing jest tests

* [PM-2319] Adding typing information for OverlayBackground

* [PM-2319] Updating typing information for the Overlay Background

* [PM-2319] Adding typing information for notification changes

* [PM-2319] Finalizing OverlayBackground typing info and removing browser autofill override method

* [PM-2319] Refining typing information within different service classes

* [PM-2319] Finalizing typing information within implementation

* [PM-2319] Further refinement and fixes for icon element

* [PM-2319] Fixing issue where submission of form and presentation of notification bar can offset the overlay element

* [PM-2319] Fixing issues present with keyboard focus and determining when to open the overlay upon user interaction

* [PM-2319] Adding in change to fix issue where autofill is occurring when iframes exist

* [PM-2319] Implementing lazy load of UI elements

* [PM-2319] Fixing issue present with lazy loading of cipher elements

* [PM-2319] Fixing issue present with lazy loading of cipher elements

* [PM-2319] Modifying offset for the ciphers list container

* [PM-2319] Fixing issue encountered with autofilling using keyboard

* [PM-2319] Modifying initialization of iframe element

* [PM-2319] Fixing an issue where login ciphers that do not contain a user name will not display within the overlay list

* [PM-2855] [PM-3034] Add Setting to Enable Autofill Overlay (#6194)

* [PM-2855] Add Settings to Enable Autofil Overlay

* [PM-2855] Adding feature flag for overlay

* [PM-2855] Implementing autofill overlay setting within browser extension

* [PM-2855] Implementing autofill overlay appearance setting

* [PM-2855] Implementing behavior within autofill overlay to conditionally display either the icon or the full list on focus of an element

* [PM-2855] Implementing a fix for when focus changes with the form field visible

* [PM-2855] Modifying rules for how the callout appears within the current-tab component

* [PM-2855] Modifying enum for autofill overlay appearance

* [PM-2855] Implementing check to ensure autofill overlay setting is not visible if the feature flag is not set

* [PM-2855] Fixing jest tests within implementation

* [PM-2855] Modifying how we pull the overlay appearance information for the end user

* [PM-2855] Applying changes to the structure for how the overlay settings are identified and verified

* [PM-2855] Applying changes to the structure for how the overlay settings are identified and verified

* [PM-2855] Adding translations content

* [PM-2855] Modifying implementation for how autofill settings populate and present themselves

* [PM-2855] Modifying implementation for how autofill settings populate and present themselves

* [PM-2855] Adding the ability to override autofill permissions within Chrome as an opt-in

* [PM-2855] Modifying message sent when vault item reprompt popout is opened

* [PM-2855] Fixing issue encountered with how we handle lazy loading vaul items

* [PM-2855] Fixing issue present when iframe is updating position when the window focus changes

* [PM-3982] Implement Autofill Overlay unit tests (#6337)

* [PM-2319] Jest Tests for Autofill Overlay MVP

* [PM-2319] Jest test stubs for OverlayBackground

* add tests and cleanup (#6341)

* [PM-3983] Implementing test for `updateAutofillOverlayCiphers`

* [PM-3983] Implementing test for `updateAutofillOverlayCiphers`

* [PM-3983] Working through jest tests for overlay background

* [PM-3983] Adding jest tests for OverlayBackground

* [PM-3983] Adding jest tests for OverlayBackground;

* [PM-3983] Adding jest tests for getAuthStatus

* [PM-3983] Adding jest tests for getAuthStatus

* [PM-3983] Adding jest tests for getTranslations

* [PM-3983] Finalizing jest tests for OverlayBackground

* [PM-3983] Finalizing jest tests for OverlayBackground

* [PM-3982] Updating unit tests within AutofillInit

* [PM-3982] Adding jest tests for AutofillOverlayIframeElement, AutofillOverlayButtonIframe, and AutofillOverlayListIframe

* [PM-3982] Adding jest tests for the AutofillOverlayIframeService class

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3992] AutofillOverlayContentService class unit tests

* [PM-3982] Filling out unit tests for the AutofillService class

* [PM-3982] Implementing unit tests for the AutofillOverlayPageElement custom element class

* [PM-3982] Updating elements to better allow for testing of the AutofillOverlayList and AutofillOverlayButton classes

* [PM-3982] Adding jest tests for AutofillOverlayList custom element class

* [PM-3982] Adding jest tests for AutofillOverlayList custom element class

* [PM-3982] Adding jest tests for the AutofillOverlayButton custom element class

* [PM-3982] Adding jest tests for the AutofillOverlayButton custom element class

* [PM-3982] Updating obsolete snapshot

* add tests for AutofillOverlayIframeService

* [PM-3982] Refactoring

* [PM-3982] Refactoring

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>

* [PM-2319] Adjusting implementation for how we open the unlock popout to facilitate skipping the notification

* [PM-2319] Adjusting typing information within the OverlayBackground class and fixing issue found within the AutofillOverlayList implementation

* [PM-2319] Adjusting JSDOC comment within NotificationBackground

* [PM-2319] Refactoring OverlayBackground tests

* [PM-2319] Refactoring OverlayBackground tests

* [PM-2319] Refactoring JSDOC comments

* [PM-2319] Adding jest tests to modified TabsBackground class

* [PM-2319] Refactoring jest tests for AutofillInit

* [PM-2319] Refactoring AutofillInit JSDOC messages

* [PM-2319] Applying refactors to AutofillInit

* [PM-2319] Applying refactors to fying info for the AutofillOverlayIframeService

* [PM-2319] Adding the ability to apply the extension theme to the overlay elements

* [PM-2319] Adjusting background offset on darker themes

* [PM-2319] Adjusting background offset on darker themes

* [PM-2319] Adding JSDOC comments to the overlay iframe service

* [PM-2319] Cleaning up implementation

* [PM-2319] Cleaning up implementation

* [PM-2319] Adding removal of unknown manifest key, `sandbox`, from the Firefox manifest

* [PM-2319] Updating manifest v3 implementation to facilitate presentation of the overlay page elements

* [PM-2319] Adding documentation to the changes to BrowserApi

* [PM-2855] Removing unnecessary key

* [PM-2319] Removing unnecesssary abstraction file

* [PM-3035] Reverting changes to package-lock.json

* [PM-3035] Reverting changes to package-lock.json

* [PM-3035] Reverting added logs

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>

* [PM-3032] Fixing issue with flashing background on overlay iframe list element

* [PM-3032] Modifying how we determine the size of the overlay button element to facilitate smaller scaling on larger sized input elements

* [PM-3032] Modifying how load actions are handled within the browser view component to clarify the triggered logic.

* [PM-3032] Adjusting implementation to how we trigger copy actions

* [PM-3032] Setting copyActions to be a static member of the view component class

* [PM-3032] Merging in changes

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>

* [PM-3914] Fixing issue within Opera where lock and login routes can persist if user opens the extension popout in a new window before locking or logging out

* [PM-3914] Setting the extensionUrls that are cheked as a variable outside of the scope fo the openUlockPopout method to ensure it does not have to be rebuilt each time the method is called

* [PM-4744] Page Details that Update after Mutation Observer has Triggered Do Not Update within Overlay Background (#6848)

* [PM-4743] Windows Chromium Browser is Not Updating Overlay Ciphers on Tab Update (#6863)

* [PM-4763] Fixing Issues with the Overlay UI Positioning and Presentation (#6864)

* [PM-4763] Fixing overlay UI issues

* [PM-4736] Implementing a method to ensure that the overlay is refreshed anytime the overlay has lost visibility

* [PM-4763] Implementing a fix for a delayed opening of the overlay element where elements in the documentElement could potentially overlay our own UI element

* [PM-4763] Implementing a fix for when the visibility of the dom changes to facilitate removing the overlay element if necessary

* [PM-4763] Fixing jest tests

* [PM-4763] Fixing global references

* [PM-4790] Overlay not resetting on scroll of websites that do not scroll body element (#6877)

* [PM-4790] Overlay not resetting on scroll of websites that do not scrollt he body element

* [PM-4790] Setting up the scroll event to capture rather than setting mousewheel and touchmove events

* [PM-4790] Setting up constants for referenced events

* [PM-4229] Fixing issue found when collecting page details

* [PM-4229] Implementing optimization to ensure we only rebuild the autofill item if the overlay needs to set the listeners on the field

* [PM-4229] Adjusting copy for autofill callout message

---------

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Manuel <mr-manuel@outlook.it>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
Cesar Gonzalez 2023-11-20 12:34:04 -06:00 committed by GitHub
parent a4b961aa0a
commit b622c38c6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 11024 additions and 477 deletions

View File

@ -60,6 +60,7 @@ function dist(browserName, manifest) {
function distFirefox() {
return dist("firefox", (manifest) => {
delete manifest.storage;
delete manifest.sandbox;
return manifest;
});
}

View File

@ -1002,6 +1002,22 @@
"environmentSaved": {
"message": "Environment URLs saved"
},
"showAutoFillMenuOnFormFields": {
"message": "Show auto-fill menu on form fields",
"description": "Represents the message for allowing the user to enable the auto-fill overlay"
},
"autofillOverlayVisibilityOff": {
"message": "Off",
"description": "Overlay setting select option for disabling autofill overlay"
},
"autofillOverlayVisibilityOnFieldFocus": {
"message": "When field is selected (on focus)",
"description": "Overlay appearance select option for showing the field on focus of the input element"
},
"autofillOverlayVisibilityOnButtonClick": {
"message": "When auto-fill icon is selected",
"description": "Overlay appearance select option for showing the field on click of the overlay icon"
},
"enableAutoFillOnPageLoad": {
"message": "Auto-fill on page load"
},
@ -1253,9 +1269,6 @@
"typeIdentity": {
"message": "Identity"
},
"typePasskey": {
"message": "Passkey"
},
"passwordHistory": {
"message": "Password history"
},
@ -2227,7 +2240,7 @@
"message": "How to auto-fill"
},
"autofillSelectInfoWithCommand": {
"message": "Select an item from this page or use the shortcut: $COMMAND$",
"message": "Select an item from this screen, use the shortcut $COMMAND$, or explore other options in settings.",
"placeholders": {
"command": {
"content": "$1",
@ -2236,7 +2249,7 @@
}
},
"autofillSelectInfoWithoutCommand": {
"message": "Select an item from this page or set a shortcut in settings."
"message": "Select an item from this screen, or explore other options in settings."
},
"gotIt": {
"message": "Got it"
@ -2457,6 +2470,80 @@
"message": "Turn off master password re-prompt to edit this field",
"description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item."
},
"bitwardenOverlayButton": {
"message": "Bitwarden auto-fill menu button",
"description": "Page title for the iframe containing the overlay button"
},
"toggleBitwardenVaultOverlay": {
"message": "Toggle Bitwarden auto-fill menu",
"description": "Screen reader and tool tip label for the overlay button"
},
"bitwardenVault": {
"message": "Bitwarden auto-fill menu",
"description": "Page title in overlay"
},
"unlockYourAccountToViewMatchingLogins": {
"message": "Unlock your account to view matching logins",
"description": "Text to display in overlay when the account is locked."
},
"unlockAccount": {
"message": "Unlock account",
"description": "Button text to display in overlay when the account is locked."
},
"fillCredentialsFor": {
"message": "Fill credentials for",
"description": "Screen reader text for when overlay item is in focused"
},
"partialUsername" : {
"message": "Partial username",
"description": "Screen reader text for when a login item is focused where a partial username is displayed. SR will announce this phrase before reading the text of the partial username"
},
"noItemsToShow": {
"message": "No items to show",
"description": "Text to show in overlay if there are no matching items"
},
"newItem": {
"message": "New item",
"description": "Button text to display in overlay when there are no matching items"
},
"addNewVaultItem": {
"message": "Add new vault item",
"description": "Screen reader text (aria-label) for new item button in overlay"
},
"bitwardenOverlayMenuAvailable": {
"message": "Bitwarden auto-fill menu available. Press the down arrow key to select.",
"description": "Screen reader text for announcing when the overlay opens on the page"
},
"overrideBrowserAutofillTitle": {
"message": "Override browser auto-fill?",
"description": "Dialog title facilitating the ability to override a chrome browser's default autofill behavior"
},
"overrideBrowserAutofillDescription": {
"message": "Leaving this setting off may cause conflicts between the Bitwarden auto-fill menu and your browsers.",
"description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior"
},
"overrideBrowserAutofillPrivacyRequiredDescription": {
"message": "Leaving this setting off may cause conflicts between the Bitwarden auto-fill menu and your browsers. Turning this on will restart the Bitwarden extension.",
"description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior"
},
"turnOn": {
"message": "Turn on"
},
"ignore": {
"message": "Ignore"
},
"overrideBrowserAutoFillSettings": {
"message": "Override browser auto-fill settings",
"description": "Label for the setting that allows overriding the default browser autofill settings"
},
"extensionPrivacyPermissionNotGrantedTitle": {
"message": "Unable to override browser auto-fill",
"description": "Title for the dialog that appears when the user has not granted the extension permission to set privacy settings"
},
"extensionPrivacyPermissionNotGrantedDescription": {
"message": "Bitwarden must have access to the extension's privacy permission to override browser auto-fill settings.",
"description": "Description for the dialog that appears when the user has not granted the extension permission to set privacy settings"
},
"importData": {
"message": "Import data",
"description": "Used for the header of the import dialog, the import button and within the file-password-prompt"

View File

@ -23,9 +23,14 @@ describe("AuthPopoutWindow", () => {
});
describe("openUnlockPopout", () => {
let senderTab: chrome.tabs.Tab;
beforeEach(() => {
senderTab = { windowId: 1 } as chrome.tabs.Tab;
});
it("opens a single action popup that allows the user to unlock the extension and sends a `bgUnlockPopoutOpened` message", async () => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
const senderTab = { windowId: 1 } as chrome.tabs.Tab;
await openUnlockPopout(senderTab);
@ -33,7 +38,17 @@ describe("AuthPopoutWindow", () => {
singleActionKey: AuthPopoutType.unlockExtension,
senderWindowId: 1,
});
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened");
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", {
skipNotification: false,
});
});
it("sends an indication that the presenting the notification bar for unlocking the extension should be skipped", async () => {
await openUnlockPopout(senderTab, true);
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", {
skipNotification: true,
});
});
it("closes any existing popup window types that are open to the unlock extension route", async () => {
@ -65,16 +80,16 @@ describe("AuthPopoutWindow", () => {
});
describe("closeUnlockPopout", () => {
it("closes the unlock extension popout window", () => {
closeUnlockPopout();
it("closes the unlock extension popout window", async () => {
await closeUnlockPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_unlockExtension");
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.unlockExtension);
});
});
describe("openSsoAuthResultPopout", () => {
it("opens a window that facilitates presentation of the results for SSO authentication", () => {
openSsoAuthResultPopout({ code: "code", state: "state" });
it("opens a window that facilitates presentation of the results for SSO authentication", async () => {
await openSsoAuthResultPopout({ code: "code", state: "state" });
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/sso?code=code&state=state", {
singleActionKey: AuthPopoutType.ssoAuthResult,
@ -83,8 +98,8 @@ describe("AuthPopoutWindow", () => {
});
describe("openTwoFactorAuthPopout", () => {
it("opens a window that facilitates two factor authentication", () => {
openTwoFactorAuthPopout({ data: "data", remember: "remember" });
it("opens a window that facilitates two factor authentication", async () => {
await openTwoFactorAuthPopout({ data: "data", remember: "remember" });
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/2fa;webAuthnResponse=data;remember=remember",
@ -94,10 +109,10 @@ describe("AuthPopoutWindow", () => {
});
describe("closeTwoFactorAuthPopout", () => {
it("closes the two-factor authentication window", () => {
closeTwoFactorAuthPopout();
it("closes the two-factor authentication window", async () => {
await closeTwoFactorAuthPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_twoFactorAuth");
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuth);
});
});
});

View File

@ -15,8 +15,9 @@ const extensionUnlockUrls = new Set([
* Opens a window that facilitates unlocking / logging into the extension.
*
* @param senderTab - Used to determine the windowId of the sender.
* @param skipNotification - Used to determine whether to show the unlock notification.
*/
async function openUnlockPopout(senderTab: chrome.tabs.Tab) {
async function openUnlockPopout(senderTab: chrome.tabs.Tab, skipNotification = false) {
const existingPopoutWindowTabs = await BrowserApi.tabsQuery({ windowType: "popup" });
existingPopoutWindowTabs.forEach((tab) => {
if (extensionUnlockUrls.has(tab.url)) {
@ -28,7 +29,7 @@ async function openUnlockPopout(senderTab: chrome.tabs.Tab) {
singleActionKey: AuthPopoutType.unlockExtension,
senderWindowId: senderTab.windowId,
});
await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened");
await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened", { skipNotification });
}
/**

View File

@ -0,0 +1,131 @@
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import AutofillPageDetails from "../../models/autofill-page-details";
type WebsiteIconData = {
imageEnabled: boolean;
image: string;
fallbackImage: string;
icon: string;
};
type OverlayAddNewItemMessage = {
login?: {
uri?: string;
hostname: string;
username: string;
password: string;
};
};
type OverlayBackgroundExtensionMessage = {
[key: string]: any;
command: string;
tab?: chrome.tabs.Tab;
sender?: string;
details?: AutofillPageDetails;
overlayElement?: string;
display?: string;
data?: {
commandToRetry?: {
msg?: {
command?: string;
};
};
};
} & OverlayAddNewItemMessage;
type OverlayPortMessage = {
[key: string]: any;
command: string;
direction?: string;
overlayCipherId?: string;
};
type FocusedFieldData = {
focusedFieldStyles: Partial<CSSStyleDeclaration>;
focusedFieldRects: Partial<DOMRect>;
};
type OverlayCipherData = {
id: string;
name: string;
type: CipherType;
reprompt: CipherRepromptType;
favorite: boolean;
icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string };
login?: { username: string };
card?: string;
};
type BackgroundMessageParam = {
message: OverlayBackgroundExtensionMessage;
};
type BackgroundSenderParam = {
sender: chrome.runtime.MessageSender;
};
type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam;
type OverlayBackgroundExtensionMessageHandlers = {
[key: string]: CallableFunction;
openAutofillOverlay: () => void;
autofillOverlayElementClosed: ({ message }: BackgroundMessageParam) => void;
autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
getAutofillOverlayVisibility: () => void;
checkAutofillOverlayFocused: () => void;
focusAutofillOverlayList: () => void;
updateAutofillOverlayPosition: ({ message }: BackgroundMessageParam) => void;
updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void;
updateFocusedFieldData: ({ message }: BackgroundMessageParam) => void;
collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
unlockCompleted: ({ message }: BackgroundMessageParam) => void;
addEditCipherSubmitted: () => void;
deletedCipher: () => void;
};
type PortMessageParam = {
message: OverlayPortMessage;
};
type PortConnectionParam = {
port: chrome.runtime.Port;
};
type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam;
type OverlayButtonPortMessageHandlers = {
[key: string]: CallableFunction;
overlayButtonClicked: ({ port }: PortConnectionParam) => void;
closeAutofillOverlay: ({ port }: PortConnectionParam) => void;
overlayPageBlurred: () => void;
redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
};
type OverlayListPortMessageHandlers = {
[key: string]: CallableFunction;
checkAutofillOverlayButtonFocused: () => void;
overlayPageBlurred: () => void;
unlockVault: ({ port }: PortConnectionParam) => void;
fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void;
addNewVaultItem: ({ port }: PortConnectionParam) => void;
viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
};
interface OverlayBackground {
init(): Promise<void>;
removePageDetails(tabId: number): void;
updateOverlayCiphers(): void;
}
export {
WebsiteIconData,
OverlayBackgroundExtensionMessage,
OverlayPortMessage,
FocusedFieldData,
OverlayCipherData,
OverlayAddNewItemMessage,
OverlayBackgroundExtensionMessageHandlers,
OverlayButtonPortMessageHandlers,
OverlayListPortMessageHandlers,
OverlayBackground,
};

View File

@ -0,0 +1,54 @@
import { mock } from "jest-mock-extended";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { BrowserStateService } from "../../platform/services/browser-state.service";
import { createChromeTabMock } from "../jest/autofill-mocks";
import AutofillService from "../services/autofill.service";
import NotificationBackground from "./notification.background";
describe("NotificationBackground", () => {
let notificationBackground: NotificationBackground;
const autofillService = mock<AutofillService>();
const cipherService = mock<CipherService>();
const authService = mock<AuthService>();
const policyService = mock<PolicyService>();
const folderService = mock<FolderService>();
const stateService = mock<BrowserStateService>();
const environmentService = mock<EnvironmentService>();
beforeEach(() => {
notificationBackground = new NotificationBackground(
autofillService,
cipherService,
authService,
policyService,
folderService,
stateService,
environmentService
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("unlockVault", () => {
it("returns early if the message indicates that the notification should be skipped", async () => {
const tabMock = createChromeTabMock();
const message = { data: { skipNotification: true } };
jest.spyOn(notificationBackground["authService"], "getAuthStatus");
jest.spyOn(notificationBackground as any, "pushUnlockVaultToQueue");
await notificationBackground["unlockVault"](message, tabMock);
expect(notificationBackground["authService"].getAuthStatus).not.toHaveBeenCalled();
expect(notificationBackground["pushUnlockVaultToQueue"]).not.toHaveBeenCalled();
});
});
});

View File

@ -122,7 +122,7 @@ export default class NotificationBackground {
}
break;
case "bgUnlockPopoutOpened":
await this.unlockVault(sender.tab);
await this.unlockVault(msg, sender.tab);
break;
case "checkNotificationQueue":
await this.checkNotificationQueue(sender.tab);
@ -330,7 +330,21 @@ export default class NotificationBackground {
}
}
private async unlockVault(tab: chrome.tabs.Tab) {
/**
* Sets up a notification to unlock the vault when the user
* attempts to autofill a cipher while the vault is locked.
*
* @param message - Extension message, determines if the notification should be skipped
* @param tab - The tab that the message was sent from
*/
private async unlockVault(
message: { data?: { skipNotification?: boolean } },
tab: chrome.tabs.Tab
) {
if (message.data?.skipNotification) {
return;
}
const currentAuthStatus = await this.authService.getAuthStatus();
if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) {
return;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,766 @@
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ThemeType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
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 { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window";
import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem";
import { BrowserApi } from "../../platform/browser/browser-api";
import {
openViewVaultItemPopout,
openAddEditVaultItemPopout,
} from "../../vault/popup/utils/vault-popout-window";
import { SHOW_AUTOFILL_BUTTON } from "../constants";
import { AutofillService, PageDetail } from "../services/abstractions/autofill.service";
import { AutofillOverlayElement, AutofillOverlayPort } from "../utils/autofill-overlay.enum";
import {
FocusedFieldData,
OverlayBackgroundExtensionMessageHandlers,
OverlayButtonPortMessageHandlers,
OverlayCipherData,
OverlayListPortMessageHandlers,
OverlayBackground as OverlayBackgroundInterface,
OverlayBackgroundExtensionMessage,
OverlayAddNewItemMessage,
OverlayPortMessage,
WebsiteIconData,
} from "./abstractions/overlay.background";
class OverlayBackground implements OverlayBackgroundInterface {
private readonly openUnlockPopout = openUnlockPopout;
private readonly openViewVaultItemPopout = openViewVaultItemPopout;
private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout;
private overlayVisibility: number;
private overlayLoginCiphers: Map<string, CipherView> = new Map();
private pageDetailsForTab: Record<number, PageDetail[]> = {};
private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
private overlayButtonPort: chrome.runtime.Port;
private overlayListPort: chrome.runtime.Port;
private focusedFieldData: FocusedFieldData;
private overlayPageTranslations: Record<string, string>;
private readonly iconsServerUrl: string;
private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = {
openAutofillOverlay: () => this.openOverlay(false),
autofillOverlayElementClosed: ({ message }) => this.overlayElementClosed(message),
autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender),
getAutofillOverlayVisibility: () => this.getOverlayVisibility(),
checkAutofillOverlayFocused: () => this.checkOverlayFocused(),
focusAutofillOverlayList: () => this.focusOverlayList(),
updateAutofillOverlayPosition: ({ message }) => this.updateOverlayPosition(message),
updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message),
updateFocusedFieldData: ({ message }) => this.setFocusedFieldData(message),
collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender),
unlockCompleted: ({ message }) => this.unlockCompleted(message),
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
deletedCipher: () => this.updateOverlayCiphers(),
};
private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = {
overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port),
closeAutofillOverlay: ({ port }) => this.closeOverlay(port),
overlayPageBlurred: () => this.checkOverlayListFocused(),
redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port),
};
private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = {
checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(),
overlayPageBlurred: () => this.checkOverlayButtonFocused(),
unlockVault: ({ port }) => this.unlockVault(port),
fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port),
addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port),
viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port),
redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port),
};
constructor(
private cipherService: CipherService,
private autofillService: AutofillService,
private authService: AuthService,
private environmentService: EnvironmentService,
private settingsService: SettingsService,
private stateService: StateService,
private i18nService: I18nService
) {
this.iconsServerUrl = this.environmentService.getIconsUrl();
}
/**
* Removes cached page details for a tab
* based on the passed tabId.
*
* @param tabId - Used to reference the page details of a specific tab
*/
removePageDetails(tabId: number) {
delete this.pageDetailsForTab[tabId];
}
/**
* Sets up the extension message listeners and gets the settings for the
* overlay's visibility and the user's authentication status.
*/
async init() {
this.setupExtensionMessageListeners();
await this.getOverlayVisibility();
await this.getAuthStatus();
}
/**
* Updates the overlay list's ciphers and sends the updated list to the overlay list iframe.
* Queries all ciphers for the given url, and sorts them by last used. Will not update the
* list of ciphers if the extension is not unlocked.
*/
async updateOverlayCiphers() {
if (this.userAuthStatus !== AuthenticationStatus.Unlocked) {
return;
}
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
if (!currentTab?.url) {
return;
}
this.overlayLoginCiphers = new Map();
const ciphersViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url)).sort(
(a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)
);
for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) {
this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]);
}
const ciphers = this.getOverlayCipherData();
this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers });
await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", {
isOverlayCiphersPopulated: Boolean(ciphers.length),
});
}
/**
* Strips out unnecessary data from the ciphers and returns an array of
* objects that contain the cipher data needed for the overlay list.
*/
private getOverlayCipherData(): OverlayCipherData[] {
const isFaviconDisabled = this.settingsService.getDisableFavicon();
const overlayCiphersArray = Array.from(this.overlayLoginCiphers);
const overlayCipherData = [];
let loginCipherIcon: WebsiteIconData;
for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) {
const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex];
if (!loginCipherIcon && cipher.type === CipherType.Login) {
loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, isFaviconDisabled);
}
overlayCipherData.push({
id: overlayCipherId,
name: cipher.name,
type: cipher.type,
reprompt: cipher.reprompt,
favorite: cipher.favorite,
icon:
cipher.type === CipherType.Login
? loginCipherIcon
: buildCipherIcon(this.iconsServerUrl, cipher, isFaviconDisabled),
login:
cipher.type === CipherType.Login
? { username: this.obscureName(cipher.login.username) }
: null,
card: cipher.type === CipherType.Card ? cipher.card.subTitle : null,
});
}
return overlayCipherData;
}
/**
* Handles aggregation of page details for a tab. Stores the page details
* in association with the tabId of the tab that sent the message.
*
* @param message - Message received from the `collectPageDetailsResponse` command
* @param sender - The sender of the message
*/
private storePageDetails(
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender
) {
const pageDetails = {
frameId: sender.frameId,
tab: sender.tab,
details: message.details,
};
if (this.pageDetailsForTab[sender.tab.id]?.length) {
this.pageDetailsForTab[sender.tab.id].push(pageDetails);
return;
}
this.pageDetailsForTab[sender.tab.id] = [pageDetails];
}
/**
* Triggers autofill for the selected cipher in the overlay list. Also places
* the selected cipher at the top of the list of ciphers.
*
* @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID.
* @param sender - The sender of the port message
*/
private async fillSelectedOverlayListItem(
{ overlayCipherId }: OverlayPortMessage,
{ sender }: chrome.runtime.Port
) {
if (!overlayCipherId) {
return;
}
const cipher = this.overlayLoginCiphers.get(overlayCipherId);
if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) {
return;
}
await this.autofillService.doAutoFill({
tab: sender.tab,
cipher: cipher,
pageDetails: this.pageDetailsForTab[sender.tab.id],
fillNewPassword: true,
allowTotpAutofill: true,
});
this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]);
}
/**
* Checks if the overlay is focused. Will check the overlay list
* if it is open, otherwise it will check the overlay button.
*/
private checkOverlayFocused() {
if (this.overlayListPort) {
this.checkOverlayListFocused();
return;
}
this.checkOverlayButtonFocused();
}
/**
* Posts a message to the overlay button iframe to check if it is focused.
*/
private checkOverlayButtonFocused() {
this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" });
}
/**
* Posts a message to the overlay list iframe to check if it is focused.
*/
private checkOverlayListFocused() {
this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" });
}
/**
* Sends a message to the sender tab to close the autofill overlay.
*
* @param sender - The sender of the port message
*/
private closeOverlay({ sender }: chrome.runtime.Port) {
BrowserApi.tabSendMessage(sender.tab, { command: "closeAutofillOverlay" });
}
/**
* Handles cleanup when an overlay element is closed. Disconnects
* the list and button ports and sets them to null.
*
* @param overlayElement - The overlay element that was closed, either the list or button
*/
private overlayElementClosed({ overlayElement }: OverlayBackgroundExtensionMessage) {
if (overlayElement === AutofillOverlayElement.Button) {
this.overlayButtonPort?.disconnect();
this.overlayButtonPort = null;
return;
}
this.overlayListPort?.disconnect();
this.overlayListPort = null;
}
/**
* Updates the position of either the overlay list or button. The position
* is based on the focused field's position and dimensions.
*
* @param overlayElement - The overlay element to update, either the list or button
*/
private updateOverlayPosition({ overlayElement }: { overlayElement?: string }) {
if (!overlayElement) {
return;
}
if (overlayElement === AutofillOverlayElement.Button) {
this.overlayButtonPort?.postMessage({
command: "updateIframePosition",
styles: this.getOverlayButtonPosition(),
});
return;
}
this.overlayListPort?.postMessage({
command: "updateIframePosition",
styles: this.getOverlayListPosition(),
});
}
/**
* Gets the position of the focused field and calculates the position
* of the overlay button based on the focused field's position and dimensions.
*/
private getOverlayButtonPosition() {
if (!this.focusedFieldData) {
return;
}
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles;
let elementOffset = height * 0.37;
if (height >= 35) {
elementOffset = height >= 50 ? height * 0.47 : height * 0.42;
}
const elementHeight = height - elementOffset;
const elementTopPosition = top + elementOffset / 2;
let elementLeftPosition = left + width - height + elementOffset / 2;
const fieldPaddingRight = parseInt(paddingRight, 10);
const fieldPaddingLeft = parseInt(paddingLeft, 10);
if (fieldPaddingRight > fieldPaddingLeft) {
elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2);
}
return {
top: `${Math.round(elementTopPosition)}px`,
left: `${Math.round(elementLeftPosition)}px`,
height: `${Math.round(elementHeight)}px`,
width: `${Math.round(elementHeight)}px`,
};
}
/**
* Gets the position of the focused field and calculates the position
* of the overlay list based on the focused field's position and dimensions.
*/
private getOverlayListPosition() {
if (!this.focusedFieldData) {
return;
}
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
return {
width: `${Math.round(width)}px`,
top: `${Math.round(top + height)}px`,
left: `${Math.round(left)}px`,
};
}
/**
* Sets the focused field data to the data passed in the extension message.
*
* @param focusedFieldData - Contains the rects and styles of the focused field.
*/
private setFocusedFieldData({ focusedFieldData }: OverlayBackgroundExtensionMessage) {
this.focusedFieldData = focusedFieldData;
}
/**
* Updates the overlay's visibility based on the display property passed in the extension message.
*
* @param display - The display property of the overlay, either "block" or "none"
*/
private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) {
if (!display) {
return;
}
const portMessage = { command: "updateOverlayHidden", styles: { display } };
this.overlayButtonPort?.postMessage(portMessage);
this.overlayListPort?.postMessage(portMessage);
}
/**
* Sends a message to the currently active tab to open the autofill overlay.
*
* @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened
* @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states
*/
private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) {
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", {
isFocusingFieldElement,
isOpeningFullOverlay,
authStatus: await this.getAuthStatus(),
});
}
/**
* Obscures the username by replacing all but the first and last characters with asterisks.
* If the username is less than 4 characters, only the first character will be shown.
* If the username is 6 or more characters, the first and last characters will be shown.
* The domain will not be obscured.
*
* @param name - The username to obscure
*/
private obscureName(name: string): string {
if (!name) {
return "";
}
const [username, domain] = name.split("@");
const usernameLength = username?.length;
if (!usernameLength) {
return name;
}
const startingCharacters = username.slice(0, usernameLength > 4 ? 2 : 1);
let numberStars = usernameLength;
if (usernameLength > 4) {
numberStars = usernameLength < 6 ? numberStars - 1 : numberStars - 2;
}
let obscureName = `${startingCharacters}${new Array(numberStars).join("*")}`;
if (usernameLength >= 6) {
obscureName = `${obscureName}${username.slice(-1)}`;
}
return domain ? `${obscureName}@${domain}` : obscureName;
}
/**
* Gets the overlay's visibility setting from the settings service.
*/
private async getOverlayVisibility(): Promise<number> {
this.overlayVisibility = await this.settingsService.getAutoFillOverlayVisibility();
return this.overlayVisibility;
}
/**
* Gets the user's authentication status from the auth service. If the user's
* authentication status has changed, the overlay button's authentication status
* will be updated and the overlay list's ciphers will be updated.
*/
private async getAuthStatus() {
const formerAuthStatus = this.userAuthStatus;
this.userAuthStatus = await this.authService.getAuthStatus();
if (
this.userAuthStatus !== formerAuthStatus &&
this.userAuthStatus === AuthenticationStatus.Unlocked
) {
this.updateOverlayButtonAuthStatus();
await this.updateOverlayCiphers();
}
return this.userAuthStatus;
}
/**
* Gets the currently set theme for the user.
*/
private async getCurrentTheme() {
const theme = await this.stateService.getTheme();
if (theme !== ThemeType.System) {
return theme;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeType.Dark
: ThemeType.Light;
}
/**
* Sends a message to the overlay button to update its authentication status.
*/
private updateOverlayButtonAuthStatus() {
this.overlayButtonPort?.postMessage({
command: "updateOverlayButtonAuthStatus",
authStatus: this.userAuthStatus,
});
}
/**
* Handles the overlay button being clicked. If the user is not authenticated,
* the vault will be unlocked. If the user is authenticated, the overlay will
* be opened.
*
* @param port - The port of the overlay button
*/
private handleOverlayButtonClicked(port: chrome.runtime.Port) {
if (this.userAuthStatus !== AuthenticationStatus.Unlocked) {
this.unlockVault(port);
return;
}
this.openOverlay(false, true);
}
/**
* Facilitates opening the unlock popout window.
*
* @param port - The port of the overlay list
*/
private async unlockVault(port: chrome.runtime.Port) {
const { sender } = port;
this.closeOverlay(port);
const retryMessage: LockedVaultPendingNotificationsItem = {
commandToRetry: { msg: { command: "openAutofillOverlay" }, sender },
target: "overlay.background",
};
await BrowserApi.tabSendMessageData(
sender.tab,
"addToLockedVaultPendingNotifications",
retryMessage
);
await this.openUnlockPopout(sender.tab, true);
}
/**
* Triggers the opening of a vault item popout window associated
* with the passed cipher ID.
* @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID.
* @param sender - The sender of the port message
*/
private async viewSelectedCipher(
{ overlayCipherId }: OverlayPortMessage,
{ sender }: chrome.runtime.Port
) {
const cipher = this.overlayLoginCiphers.get(overlayCipherId);
if (!cipher) {
return;
}
await this.openViewVaultItemPopout(sender.tab, {
cipherId: cipher.id,
action: SHOW_AUTOFILL_BUTTON,
});
}
/**
* Facilitates redirecting focus to the overlay list.
*/
private focusOverlayList() {
this.overlayListPort?.postMessage({ command: "focusOverlayList" });
}
/**
* Updates the authentication status for the user and opens the overlay if
* a followup command is present in the message.
*
* @param message - Extension message received from the `unlockCompleted` command
*/
private async unlockCompleted(message: OverlayBackgroundExtensionMessage) {
await this.getAuthStatus();
if (message.data?.commandToRetry?.msg?.command === "openAutofillOverlay") {
await this.openOverlay(true);
}
}
/**
* Gets the translations for the overlay page.
*/
private getTranslations() {
if (!this.overlayPageTranslations) {
this.overlayPageTranslations = {
locale: BrowserApi.getUILanguage(),
opensInANewWindow: this.i18nService.translate("opensInANewWindow"),
buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"),
toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"),
listPageTitle: this.i18nService.translate("bitwardenVault"),
unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"),
unlockAccount: this.i18nService.translate("unlockAccount"),
fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"),
partialUsername: this.i18nService.translate("partialUsername"),
view: this.i18nService.translate("view"),
noItemsToShow: this.i18nService.translate("noItemsToShow"),
newItem: this.i18nService.translate("newItem"),
addNewVaultItem: this.i18nService.translate("addNewVaultItem"),
};
}
return this.overlayPageTranslations;
}
/**
* Facilitates redirecting focus out of one of the
* overlay elements to elements on the page.
*
* @param direction - The direction to redirect focus to (either "next", "previous" or "current)
* @param sender - The sender of the port message
*/
private redirectOverlayFocusOut(
{ direction }: OverlayPortMessage,
{ sender }: chrome.runtime.Port
) {
if (!direction) {
return;
}
BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction });
}
/**
* Triggers adding a new vault item from the overlay. Gathers data
* input by the user before calling to open the add/edit window.
*
* @param sender - The sender of the port message
*/
private getNewVaultItemDetails({ sender }: chrome.runtime.Port) {
BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" });
}
/**
* Handles adding a new vault item from the overlay. Gathers data login
* data captured in the extension message.
*
* @param login - The login data captured from the extension message
* @param sender - The sender of the extension message
*/
private async addNewVaultItem(
{ login }: OverlayAddNewItemMessage,
sender: chrome.runtime.MessageSender
) {
if (!login) {
return;
}
const uriView = new LoginUriView();
uriView.uri = login.uri;
const loginView = new LoginView();
loginView.uris = [uriView];
loginView.username = login.username || "";
loginView.password = login.password || "";
const cipherView = new CipherView();
cipherView.name = (Utils.getHostname(login.uri) || login.hostname).replace(/^www\./, "");
cipherView.folderId = null;
cipherView.type = CipherType.Login;
cipherView.login = loginView;
await this.stateService.setAddEditCipherInfo({
cipher: cipherView,
collectionIds: cipherView.collectionIds,
});
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
}
/**
* Sets up the extension message listeners for the overlay.
*/
private setupExtensionMessageListeners() {
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
chrome.runtime.onConnect.addListener(this.handlePortOnConnect);
}
/**
* Handles extension messages sent to the extension background.
*
* @param message - The message received from the extension
* @param sender - The sender of the message
* @param sendResponse - The response to send back to the sender
*/
private handleExtensionMessage = (
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void
) => {
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
if (!handler) {
return;
}
const messageResponse = handler({ message, sender });
if (!messageResponse) {
return;
}
Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true;
};
/**
* Handles the connection of a port to the extension background.
*
* @param port - The port that connected to the extension background
*/
private handlePortOnConnect = async (port: chrome.runtime.Port) => {
const isOverlayListPort = port.name === AutofillOverlayPort.List;
const isOverlayButtonPort = port.name === AutofillOverlayPort.Button;
if (!isOverlayListPort && !isOverlayButtonPort) {
return;
}
if (isOverlayListPort) {
this.overlayListPort = port;
} else {
this.overlayButtonPort = port;
}
port.onMessage.addListener(this.handleOverlayElementPortMessage);
port.postMessage({
command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`,
authStatus: await this.getAuthStatus(),
styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`),
theme: `theme_${await this.getCurrentTheme()}`,
translations: this.getTranslations(),
ciphers: isOverlayListPort ? this.getOverlayCipherData() : null,
});
this.updateOverlayPosition({
overlayElement: isOverlayListPort
? AutofillOverlayElement.List
: AutofillOverlayElement.Button,
});
};
/**
* Handles messages sent to the overlay list or button ports.
*
* @param message - The message received from the port
* @param port - The port that sent the message
*/
private handleOverlayElementPortMessage = (
message: OverlayBackgroundExtensionMessage,
port: chrome.runtime.Port
) => {
const command = message?.command;
let handler: CallableFunction | undefined;
if (port.name === AutofillOverlayPort.Button) {
handler = this.overlayButtonPortMessageHandlers[command];
}
if (port.name === AutofillOverlayPort.List) {
handler = this.overlayListPortMessageHandlers[command];
}
if (!handler) {
return;
}
handler({ message, port });
};
}
export default OverlayBackground;

View File

@ -0,0 +1,238 @@
import { mock } from "jest-mock-extended";
import MainBackground from "../../background/main.background";
import {
flushPromises,
triggerTabOnActivatedEvent,
triggerTabOnRemovedEvent,
triggerTabOnReplacedEvent,
triggerTabOnUpdatedEvent,
triggerWindowOnFocusedChangedEvent,
} from "../jest/testing-utils";
import NotificationBackground from "./notification.background";
import OverlayBackground from "./overlay.background";
import TabsBackground from "./tabs.background";
describe("TabsBackground", () => {
let tabsBackgorund: TabsBackground;
const mainBackground = mock<MainBackground>({
messagingService: {
send: jest.fn(),
},
});
const notificationBackground = mock<NotificationBackground>();
const overlayBackground = mock<OverlayBackground>();
beforeEach(() => {
tabsBackgorund = new TabsBackground(mainBackground, notificationBackground, overlayBackground);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("init", () => {
it("sets up a window on focusChanged listener", () => {
const handleWindowOnFocusChangedSpy = jest.spyOn(
tabsBackgorund as any,
"handleWindowOnFocusChanged"
);
tabsBackgorund.init();
expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith(
handleWindowOnFocusChangedSpy
);
});
});
describe("tab event listeners", () => {
beforeEach(() => {
tabsBackgorund.init();
});
describe("window onFocusChanged event", () => {
it("ignores focus change events that do not contain a windowId", async () => {
triggerWindowOnFocusedChangedEvent(undefined);
await flushPromises();
expect(mainBackground.messagingService.send).not.toHaveBeenCalled();
});
it("sets the local focusedWindowId property", async () => {
triggerWindowOnFocusedChangedEvent(10);
await flushPromises();
expect(tabsBackgorund["focusedWindowId"]).toBe(10);
});
it("updates the current tab data", async () => {
triggerWindowOnFocusedChangedEvent(10);
await flushPromises();
expect(mainBackground.refreshBadge).toHaveBeenCalled();
expect(mainBackground.refreshMenu).toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled();
});
it("sends a `windowChanged` message", async () => {
triggerWindowOnFocusedChangedEvent(10);
await flushPromises();
expect(mainBackground.messagingService.send).toHaveBeenCalledWith("windowChanged");
});
});
describe("handleTabOnActivated", () => {
it("updates the current tab data", async () => {
triggerTabOnActivatedEvent({ tabId: 10, windowId: 20 });
await flushPromises();
expect(mainBackground.refreshBadge).toHaveBeenCalled();
expect(mainBackground.refreshMenu).toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled();
});
it("sends a `tabChanged` message to the messaging service", async () => {
triggerTabOnActivatedEvent({ tabId: 10, windowId: 20 });
await flushPromises();
expect(mainBackground.messagingService.send).toHaveBeenCalledWith("tabChanged");
});
});
describe("handleTabOnReplaced", () => {
beforeEach(() => {
mainBackground.onReplacedRan = false;
});
it("ignores the event if the `onReplacedRan` property of the main background class is set to `true`", () => {
mainBackground.onReplacedRan = true;
triggerTabOnReplacedEvent(10, 20);
expect(notificationBackground.checkNotificationQueue).not.toHaveBeenCalled();
});
it("checks the notification queue", () => {
triggerTabOnReplacedEvent(10, 20);
expect(notificationBackground.checkNotificationQueue).toHaveBeenCalled();
});
it("updates the current tab data", async () => {
triggerTabOnReplacedEvent(10, 20);
await flushPromises();
expect(mainBackground.refreshBadge).toHaveBeenCalled();
expect(mainBackground.refreshMenu).toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled();
});
it("sends a `tabChanged` message to the messaging service", async () => {
triggerTabOnReplacedEvent(10, 20);
await flushPromises();
expect(mainBackground.messagingService.send).toHaveBeenCalledWith("tabChanged");
});
});
describe("handleTabOnUpdated", () => {
const focusedWindowId = 10;
let tab: chrome.tabs.Tab;
beforeEach(() => {
mainBackground.onUpdatedRan = false;
tabsBackgorund["focusedWindowId"] = focusedWindowId;
tab = mock<chrome.tabs.Tab>({
windowId: focusedWindowId,
active: true,
status: "loading",
});
});
it("removes the cached page details from the overlay background if the tab status is `loading`", () => {
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(focusedWindowId);
});
it("removes the cached page details from the overlay background if the tab status is `unloaded`", () => {
triggerTabOnUpdatedEvent(focusedWindowId, { status: "unloaded" }, tab);
expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(focusedWindowId);
});
it("skips updating the current tab data the focusedWindowId is set to a value less than zero", async () => {
tab.windowId = -1;
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(mainBackground.refreshBadge).not.toHaveBeenCalled();
expect(mainBackground.refreshMenu).not.toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled();
});
it("skips updating the current tab data if the updated tab is not for the focusedWindowId", async () => {
tab.windowId = 20;
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(mainBackground.refreshBadge).not.toHaveBeenCalled();
expect(mainBackground.refreshMenu).not.toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled();
});
it("skips updating the current tab data if the updated tab is not active", async () => {
tab.active = false;
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(mainBackground.refreshBadge).not.toHaveBeenCalled();
expect(mainBackground.refreshMenu).not.toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled();
});
it("skips updating the badge, context menu and notification bar if the `onUpdatedRan` property of the main background class is set to `true`", async () => {
mainBackground.onUpdatedRan = true;
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(mainBackground.refreshBadge).not.toHaveBeenCalled();
expect(mainBackground.refreshMenu).not.toHaveBeenCalled();
});
it("checks the notification queue", async () => {
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(notificationBackground.checkNotificationQueue).toHaveBeenCalled();
});
it("updates the current tab data", async () => {
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(mainBackground.refreshBadge).toHaveBeenCalled();
expect(mainBackground.refreshMenu).toHaveBeenCalled();
expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled();
});
it("sends a `tabChanged` message to the messaging service", async () => {
triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab);
await flushPromises();
expect(mainBackground.messagingService.send).toHaveBeenCalledWith("tabChanged");
});
});
describe("handleTabOnRemoved", () => {
it("removes the cached overlay page details", () => {
triggerTabOnRemovedEvent(10, { windowId: 20, isWindowClosing: false });
expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(10);
});
});
});
});

View File

@ -1,52 +1,87 @@
import MainBackground from "../../background/main.background";
import NotificationBackground from "./notification.background";
import OverlayBackground from "./overlay.background";
export default class TabsBackground {
constructor(
private main: MainBackground,
private notificationBackground: NotificationBackground
private notificationBackground: NotificationBackground,
private overlayBackground: OverlayBackground
) {}
private focusedWindowId: number;
/**
* Initializes the window and tab listeners.
*/
async init() {
if (!chrome.tabs || !chrome.windows) {
return;
}
chrome.windows.onFocusChanged.addListener(async (windowId: number) => {
if (windowId === null || windowId < 0) {
chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged);
chrome.tabs.onActivated.addListener(this.handleTabOnActivated);
chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced);
chrome.tabs.onUpdated.addListener(this.handleTabOnUpdated);
chrome.tabs.onRemoved.addListener(this.handleTabOnRemoved);
}
/**
* Handles the window onFocusChanged event.
*
* @param windowId - The ID of the window that was focused.
*/
private handleWindowOnFocusChanged = async (windowId: number) => {
if (!windowId) {
return;
}
this.focusedWindowId = windowId;
await this.main.refreshBadge();
await this.main.refreshMenu();
await this.updateCurrentTabData();
this.main.messagingService.send("windowChanged");
});
};
chrome.tabs.onActivated.addListener(async (activeInfo: chrome.tabs.TabActiveInfo) => {
await this.main.refreshBadge();
await this.main.refreshMenu();
/**
* Handles the tab onActivated event.
*/
private handleTabOnActivated = async () => {
await this.updateCurrentTabData();
this.main.messagingService.send("tabChanged");
});
};
chrome.tabs.onReplaced.addListener(async (addedTabId: number, removedTabId: number) => {
/**
* Handles the tab onReplaced event.
*/
private handleTabOnReplaced = async () => {
if (this.main.onReplacedRan) {
return;
}
this.main.onReplacedRan = true;
await this.notificationBackground.checkNotificationQueue();
await this.main.refreshBadge();
await this.main.refreshMenu();
await this.updateCurrentTabData();
this.main.messagingService.send("tabChanged");
});
};
chrome.tabs.onUpdated.addListener(
async (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
if (this.focusedWindowId > 0 && tab.windowId != this.focusedWindowId) {
/**
* Handles the tab onUpdated event.
*
* @param tabId - The ID of the tab that was updated.
* @param changeInfo - The change information.
* @param tab - The updated tab.
*/
private handleTabOnUpdated = async (
tabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab
) => {
const removePageDetailsStatus = new Set(["loading", "unloaded"]);
if (removePageDetailsStatus.has(changeInfo.status)) {
this.overlayBackground.removePageDetails(tabId);
}
if (this.focusedWindowId > 0 && tab.windowId !== this.focusedWindowId) {
return;
}
@ -54,6 +89,8 @@ export default class TabsBackground {
return;
}
await this.overlayBackground.updateOverlayCiphers();
if (this.main.onUpdatedRan) {
return;
}
@ -63,7 +100,24 @@ export default class TabsBackground {
await this.main.refreshBadge();
await this.main.refreshMenu();
this.main.messagingService.send("tabChanged");
}
);
}
};
/**
* Handles the tab onRemoved event.
*
* @param tabId - The ID of the tab that was removed.
*/
private handleTabOnRemoved = async (tabId: number) => {
this.overlayBackground.removePageDetails(tabId);
};
/**
* Updates the current tab data, refreshing the badge and context menu
* for the current tab. Also updates the overlay ciphers.
*/
private updateCurrentTabData = async () => {
await this.main.refreshBadge();
await this.main.refreshMenu();
await this.overlayBackground.updateOverlayCiphers();
};
}

View File

@ -15,7 +15,7 @@ import {
AUTOFILL_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATIONCODE_ID,
COPY_VERIFICATION_CODE_ID,
GENERATE_PASSWORD_ID,
NOOP_COMMAND_SUFFIX,
} from "../constants";
@ -165,7 +165,7 @@ describe("ContextMenuClickedHandler", () => {
return Promise.resolve("654321");
});
await sut.run(createData(`${COPY_VERIFICATIONCODE_ID}_1`, COPY_VERIFICATIONCODE_ID), {
await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), {
url: "https://test.com",
} as any);

View File

@ -44,7 +44,7 @@ import {
COPY_IDENTIFIER_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATIONCODE_ID,
COPY_VERIFICATION_CODE_ID,
CREATE_CARD_ID,
CREATE_IDENTITY_ID,
CREATE_LOGIN_ID,
@ -281,7 +281,7 @@ export class ContextMenuClickedHandler {
}
break;
case COPY_VERIFICATIONCODE_ID:
case COPY_VERIFICATION_CODE_ID:
if (menuItemId === CREATE_LOGIN_ID) {
await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
break;
@ -290,7 +290,7 @@ export class ContextMenuClickedHandler {
if (await this.isPasswordRepromptRequired(cipher)) {
await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: COPY_VERIFICATIONCODE_ID,
action: COPY_VERIFICATION_CODE_ID,
});
} else {
this.copyToClipboard({

View File

@ -28,7 +28,7 @@ import {
COPY_IDENTIFIER_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATIONCODE_ID,
COPY_VERIFICATION_CODE_ID,
CREATE_CARD_ID,
CREATE_IDENTITY_ID,
CREATE_LOGIN_ID,
@ -139,7 +139,7 @@ export class MainContextMenuHandler {
if (await this.stateService.getCanAccessPremium()) {
await create({
id: COPY_VERIFICATIONCODE_ID,
id: COPY_VERIFICATION_CODE_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyVerificationCode"),
});
@ -250,7 +250,7 @@ export class MainContextMenuHandler {
const canAccessPremium = await this.stateService.getCanAccessPremium();
if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) {
await createChildItem(COPY_VERIFICATIONCODE_ID);
await createChildItem(COPY_VERIFICATION_CODE_ID);
}
if ((!cipher || cipher.type === CipherType.Card) && optionId !== CREATE_LOGIN_ID) {

View File

@ -10,16 +10,27 @@ export const EVENTS = {
KEYDOWN: "keydown",
KEYPRESS: "keypress",
KEYUP: "keyup",
BLUR: "blur",
CLICK: "click",
FOCUS: "focus",
SCROLL: "scroll",
RESIZE: "resize",
DOMCONTENTLOADED: "DOMContentLoaded",
LOAD: "load",
MESSAGE: "message",
VISIBILITYCHANGE: "visibilitychange",
FOCUSOUT: "focusout",
} as const;
/* Context Menu item Ids */
export const AUTOFILL_CARD_ID = "autofill-card";
export const AUTOFILL_ID = "autofill";
export const SHOW_AUTOFILL_BUTTON = "show-autofill-button";
export const AUTOFILL_IDENTITY_ID = "autofill-identity";
export const COPY_IDENTIFIER_ID = "copy-identifier";
export const COPY_PASSWORD_ID = "copy-password";
export const COPY_USERNAME_ID = "copy-username";
export const COPY_VERIFICATIONCODE_ID = "copy-totp";
export const COPY_VERIFICATION_CODE_ID = "copy-totp";
export const CREATE_CARD_ID = "create-card";
export const CREATE_IDENTITY_ID = "create-identity";
export const CREATE_LOGIN_ID = "create-login";

View File

@ -1,3 +1,5 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import AutofillScript from "../../models/autofill-script";
type AutofillExtensionMessage = {
@ -7,13 +9,30 @@ type AutofillExtensionMessage = {
fillScript?: AutofillScript;
url?: string;
pageDetailsUrl?: string;
ciphers?: any;
data?: {
authStatus?: AuthenticationStatus;
isFocusingFieldElement?: boolean;
isOverlayCiphersPopulated?: boolean;
direction?: "previous" | "next";
isOpeningFullOverlay?: boolean;
};
};
type AutofillExtensionMessageParam = { message: AutofillExtensionMessage };
type AutofillExtensionMessageHandlers = {
[key: string]: CallableFunction;
collectPageDetails: (message: { message: AutofillExtensionMessage }) => void;
collectPageDetailsImmediately: (message: { message: AutofillExtensionMessage }) => void;
fillForm: (message: { message: AutofillExtensionMessage }) => void;
collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void;
collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void;
fillForm: ({ message }: AutofillExtensionMessageParam) => void;
openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void;
closeAutofillOverlay: () => void;
addNewVaultItemFromOverlay: () => void;
redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void;
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
bgUnlockPopoutOpened: () => void;
bgVaultItemRepromptPopoutOpened: () => void;
};
interface AutofillInit {

View File

@ -1,16 +1,22 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { flushPromises, sendExtensionRuntimeMessage } from "../jest/testing-utils";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
import { RedirectFocusDirection } from "../utils/autofill-overlay.enum";
import { AutofillExtensionMessage } from "./abstractions/autofill-init";
import AutofillInit from "./autofill-init";
describe("AutofillInit", () => {
let bitwardenAutofillInit: any;
let autofillInit: AutofillInit;
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
beforeEach(() => {
require("../content/autofill-init");
bitwardenAutofillInit = window.bitwardenAutofillInit;
autofillInit = new AutofillInit(autofillOverlayContentService);
});
afterEach(() => {
@ -20,93 +26,11 @@ describe("AutofillInit", () => {
describe("init", () => {
it("sets up the extension message listeners", () => {
jest.spyOn(bitwardenAutofillInit, "setupExtensionMessageListeners");
jest.spyOn(autofillInit as any, "setupExtensionMessageListeners");
bitwardenAutofillInit.init();
autofillInit.init();
expect(bitwardenAutofillInit.setupExtensionMessageListeners).toHaveBeenCalled();
});
});
describe("collectPageDetails", () => {
let extensionMessage: AutofillExtensionMessage;
let pageDetails: AutofillPageDetails;
beforeEach(() => {
extensionMessage = {
command: "collectPageDetails",
tab: mock<chrome.tabs.Tab>(),
sender: "sender",
};
pageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
jest
.spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails")
.mockReturnValue(pageDetails);
});
it("returns collected page details for autofill if set to send the details in the response", async () => {
const response = await bitwardenAutofillInit["collectPageDetails"](extensionMessage, true);
expect(bitwardenAutofillInit.collectAutofillContentService.getPageDetails).toHaveBeenCalled();
expect(response).toEqual(pageDetails);
});
it("sends the collected page details for autofill using a background script message", async () => {
jest.spyOn(chrome.runtime, "sendMessage");
await bitwardenAutofillInit["collectPageDetails"](extensionMessage);
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "collectPageDetailsResponse",
tab: extensionMessage.tab,
details: pageDetails,
sender: extensionMessage.sender,
});
});
});
describe("fillForm", () => {
beforeEach(() => {
jest
.spyOn(bitwardenAutofillInit.insertAutofillContentService, "fillForm")
.mockImplementation();
});
it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", () => {
const fillScript = mock<AutofillScript>();
const message = {
command: "fillForm",
fillScript,
pageDetailsUrl: "https://a-different-url.com",
};
bitwardenAutofillInit.fillForm(message);
expect(bitwardenAutofillInit.insertAutofillContentService.fillForm).not.toHaveBeenCalledWith(
fillScript
);
});
it("will call the InsertAutofillContentService to fill the form", () => {
const fillScript = mock<AutofillScript>();
const message = {
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
};
bitwardenAutofillInit.fillForm(message);
expect(bitwardenAutofillInit.insertAutofillContentService.fillForm).toHaveBeenCalledWith(
fillScript
);
expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled();
});
});
@ -114,10 +38,10 @@ describe("AutofillInit", () => {
it("sets up a chrome runtime on message listener", () => {
jest.spyOn(chrome.runtime.onMessage, "addListener");
bitwardenAutofillInit["setupExtensionMessageListeners"]();
autofillInit["setupExtensionMessageListeners"]();
expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith(
bitwardenAutofillInit["handleExtensionMessage"]
autofillInit["handleExtensionMessage"]
);
});
});
@ -136,38 +60,27 @@ describe("AutofillInit", () => {
sender = mock<chrome.runtime.MessageSender>();
});
it("returns a false value if a extension message handler is not found with the given message command", () => {
it("returns a undefined value if a extension message handler is not found with the given message command", () => {
message.command = "unknownCommand";
const response = bitwardenAutofillInit["handleExtensionMessage"](
message,
sender,
sendResponse
);
const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse);
expect(response).toBe(false);
expect(response).toBe(undefined);
});
it("returns a false value if the message handler does not return a response", async () => {
const response1 = await bitwardenAutofillInit["handleExtensionMessage"](
message,
sender,
sendResponse
);
await Promise.resolve(response1);
it("returns a undefined value if the message handler does not return a response", async () => {
const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response1).not.toBe(false);
message.command = "fillForm";
message.command = "removeAutofillOverlay";
message.fillScript = mock<AutofillScript>();
const response2 = await bitwardenAutofillInit["handleExtensionMessage"](
message,
sender,
sendResponse
);
const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response2).toBe(false);
expect(response2).toBe(undefined);
});
it("returns a true value and calls sendResponse if the message handler returns a response", async () => {
@ -181,18 +94,365 @@ describe("AutofillInit", () => {
collectedTimestamp: 0,
};
jest
.spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails")
.mockReturnValue(pageDetails);
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
const response = await bitwardenAutofillInit["handleExtensionMessage"](
message,
sender,
sendResponse
);
const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await Promise.resolve(response);
expect(response).toBe(true);
expect(sendResponse).toHaveBeenCalledWith(pageDetails);
});
describe("extension message handlers", () => {
beforeEach(() => {
autofillInit.init();
});
describe("collectPageDetails", () => {
it("sends the collected page details for autofill using a background script message", async () => {
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
const message = {
command: "collectPageDetails",
sender: "sender",
tab: mock<chrome.tabs.Tab>(),
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
sendExtensionRuntimeMessage(message, sender, sendResponse);
await flushPromises();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
});
});
describe("collectPageDetailsImmediately", () => {
it("returns collected page details for autofill if set to send the details in the response", async () => {
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
sendExtensionRuntimeMessage(
{ command: "collectPageDetailsImmediately" },
sender,
sendResponse
);
await flushPromises();
expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled();
expect(sendResponse).toBeCalledWith(pageDetails);
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
});
});
describe("fillForm", () => {
let fillScript: AutofillScript;
beforeEach(() => {
fillScript = mock<AutofillScript>();
jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation();
});
it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => {
const fillScript = mock<AutofillScript>();
const message = {
command: "fillForm",
fillScript,
pageDetailsUrl: "https://a-different-url.com",
};
sendExtensionRuntimeMessage(message);
await flushPromises();
expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith(
fillScript
);
});
it("calls the InsertAutofillContentService to fill the form", async () => {
sendExtensionRuntimeMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript
);
});
it("updates the isCurrentlyFilling properties of the overlay and focus the recent field after filling", async () => {
jest.useFakeTimers();
jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling");
jest
.spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField")
.mockImplementation();
sendExtensionRuntimeMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
jest.advanceTimersByTime(300);
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true);
expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript
);
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false);
expect(
autofillInit["autofillOverlayContentService"].focusMostRecentOverlayField
).toHaveBeenCalled();
});
it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => {
jest.useFakeTimers();
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling");
jest
.spyOn(newAutofillInit["insertAutofillContentService"], "fillForm")
.mockImplementation();
sendExtensionRuntimeMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
jest.advanceTimersByTime(300);
expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(
1,
true
);
expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript
);
expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith(
2,
false
);
});
});
describe("openAutofillOverlay", () => {
const message = {
command: "openAutofillOverlay",
data: {
isFocusingFieldElement: true,
isOpeningFullOverlay: true,
authStatus: AuthenticationStatus.Unlocked,
},
};
it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
sendExtensionRuntimeMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("opens the autofill overlay", () => {
sendExtensionRuntimeMessage(message);
expect(
autofillInit["autofillOverlayContentService"].openAutofillOverlay
).toHaveBeenCalledWith({
isFocusingFieldElement: message.data.isFocusingFieldElement,
isOpeningFullOverlay: message.data.isOpeningFullOverlay,
authStatus: message.data.authStatus,
});
});
});
describe("closeAutofillOverlay", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false;
autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false;
});
it("ignores the message if a field is currently focused", () => {
autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true;
sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList
).not.toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay
).not.toHaveBeenCalled();
});
it("removes the autofill overlay list if the overlay is currently filling", () => {
autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true;
sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList
).toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay
).not.toHaveBeenCalled();
});
it("removes the entire overlay if the overlay is not currently filling", () => {
sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList
).not.toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay
).toHaveBeenCalled();
});
});
describe("addNewVaultItemFromOverlay", () => {
it("will not add a new vault item if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("will add a new vault item", () => {
sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" });
expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled();
});
});
describe("redirectOverlayFocusOut", () => {
const message = {
command: "redirectOverlayFocusOut",
data: {
direction: RedirectFocusDirection.Next,
},
};
it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => {
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
sendExtensionRuntimeMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("redirects the overlay focus", () => {
sendExtensionRuntimeMessage(message);
expect(
autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut
).toHaveBeenCalledWith(message.data.direction);
});
});
describe("updateIsOverlayCiphersPopulated", () => {
const message = {
command: "updateIsOverlayCiphersPopulated",
data: {
isOverlayCiphersPopulated: true,
},
};
it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => {
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
sendExtensionRuntimeMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("updates whether the overlay ciphers are populated", () => {
sendExtensionRuntimeMessage(message);
expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual(
message.data.isOverlayCiphersPopulated
);
});
});
describe("bgUnlockPopoutOpened", () => {
it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled();
});
it("blurs the most recently focused feel and remove the autofill overlay", () => {
jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField");
jest.spyOn(autofillInit as any, "removeAutofillOverlay");
sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" });
expect(
autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField
).toHaveBeenCalled();
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("bgVaultItemRepromptPopoutOpened", () => {
it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInit(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled();
});
it("blurs the most recently focused feel and remove the autofill overlay", () => {
jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField");
jest.spyOn(autofillInit as any, "removeAutofillOverlay");
sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" });
expect(
autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField
).toHaveBeenCalled();
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
});
});
});

View File

@ -1,4 +1,5 @@
import AutofillPageDetails from "../models/autofill-page-details";
import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service";
import CollectAutofillContentService from "../services/collect-autofill-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import InsertAutofillContentService from "../services/insert-autofill-content.service";
@ -10,6 +11,7 @@ import {
} from "./abstractions/autofill-init";
class AutofillInit implements AutofillInitInterface {
private readonly autofillOverlayContentService: AutofillOverlayContentService | undefined;
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly collectAutofillContentService: CollectAutofillContentService;
private readonly insertAutofillContentService: InsertAutofillContentService;
@ -17,16 +19,27 @@ class AutofillInit implements AutofillInitInterface {
collectPageDetails: ({ message }) => this.collectPageDetails(message),
collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true),
fillForm: ({ message }) => this.fillForm(message),
openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message),
closeAutofillOverlay: () => this.removeAutofillOverlay(),
addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(),
redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message),
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
};
/**
* AutofillInit constructor. Initializes the DomElementVisibilityService,
* CollectAutofillContentService and InsertAutofillContentService classes.
*
* @param autofillOverlayContentService - The autofill overlay content service, potentially undefined.
*/
constructor() {
constructor(autofillOverlayContentService?: AutofillOverlayContentService) {
this.autofillOverlayContentService = autofillOverlayContentService;
this.domElementVisibilityService = new DomElementVisibilityService();
this.collectAutofillContentService = new CollectAutofillContentService(
this.domElementVisibilityService
this.domElementVisibilityService,
this.autofillOverlayContentService
);
this.insertAutofillContentService = new InsertAutofillContentService(
this.domElementVisibilityService,
@ -38,10 +51,10 @@ class AutofillInit implements AutofillInitInterface {
* Initializes the autofill content script, setting up
* the extension message listeners. This method should
* be called once when the content script is loaded.
* @public
*/
init() {
this.setupExtensionMessageListeners();
this.autofillOverlayContentService?.init();
}
/**
@ -50,10 +63,9 @@ class AutofillInit implements AutofillInitInterface {
* parameter is set to true, the page details will be
* returned to facilitate sending the details in the
* response to the extension message.
* @param {AutofillExtensionMessage} message
* @param {boolean} sendDetailsInResponse
* @returns {AutofillPageDetails | void}
* @private
*
* @param message - The extension message.
* @param sendDetailsInResponse - Determines whether to send the details in the response.
*/
private async collectPageDetails(
message: AutofillExtensionMessage,
@ -78,31 +90,138 @@ class AutofillInit implements AutofillInitInterface {
*
* @param {AutofillExtensionMessage} message
*/
private fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) {
private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) {
if ((document.defaultView || window).location.href !== pageDetailsUrl) {
return;
}
this.insertAutofillContentService.fillForm(fillScript);
this.updateOverlayIsCurrentlyFilling(true);
await this.insertAutofillContentService.fillForm(fillScript);
if (!this.autofillOverlayContentService) {
return;
}
setTimeout(() => {
this.updateOverlayIsCurrentlyFilling(false);
this.autofillOverlayContentService.focusMostRecentOverlayField();
}, 250);
}
/**
* Sets up the extension message listeners
* for the content script.
* @private
* Handles updating the overlay is currently filling value.
*
* @param isCurrentlyFilling - Indicates if the overlay is currently filling
*/
private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling;
}
/**
* Opens the autofill overlay.
*
* @param data - The extension message data.
*/
private openAutofillOverlay({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.openAutofillOverlay(data);
}
/**
* Blurs the most recent overlay field and removes the overlay. Used
* in cases where the background unlock or vault item reprompt popout
* is opened.
*/
private blurAndRemoveOverlay() {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.blurMostRecentOverlayField();
this.removeAutofillOverlay();
}
/**
* Removes the autofill overlay if the field is not currently focused.
* If the autofill is currently filling, only the overlay list will be
* removed.
*/
private removeAutofillOverlay() {
if (
!this.autofillOverlayContentService ||
this.autofillOverlayContentService.isFieldCurrentlyFocused
) {
return;
}
if (this.autofillOverlayContentService.isCurrentlyFilling) {
this.autofillOverlayContentService.removeAutofillOverlayList();
return;
}
this.autofillOverlayContentService.removeAutofillOverlay();
}
/**
* Adds a new vault item from the overlay.
*/
private addNewVaultItemFromOverlay() {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.addNewVaultItem();
}
/**
* Redirects the overlay focus out of an overlay iframe.
*
* @param data - Contains the direction to redirect the focus.
*/
private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction);
}
/**
* Updates whether the current tab has ciphers that can populate the overlay list
*
* @param data - Contains the isOverlayCiphersPopulated value
*
*/
private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean(
data?.isOverlayCiphersPopulated
);
}
/**
* Sets up the extension message listeners for the content script.
*/
private setupExtensionMessageListeners() {
chrome.runtime.onMessage.addListener(this.handleExtensionMessage);
}
/**
* Handles the extension messages
* sent to the content script.
* @param {AutofillExtensionMessage} message
* @param {chrome.runtime.MessageSender} sender
* @param {(response?: any) => void} sendResponse
* @returns {boolean}
* @private
* Handles the extension messages sent to the content script.
*
* @param message - The extension message.
* @param sender - The message sender.
* @param sendResponse - The send response callback.
*/
private handleExtensionMessage = (
message: AutofillExtensionMessage,
@ -112,12 +231,12 @@ class AutofillInit implements AutofillInitInterface {
const command: string = message.command;
const handler: CallableFunction | undefined = this.extensionMessageHandlers[command];
if (!handler) {
return false;
return;
}
const messageResponse = handler({ message, sender });
if (!messageResponse) {
return false;
return;
}
Promise.resolve(messageResponse).then((response) => sendResponse(response));
@ -125,9 +244,4 @@ class AutofillInit implements AutofillInitInterface {
};
}
(function () {
if (!window.bitwardenAutofillInit) {
window.bitwardenAutofillInit = new AutofillInit();
window.bitwardenAutofillInit.init();
}
})();
export default AutofillInit;

View File

@ -34,3 +34,10 @@ span[data-bwautofill].com-bitwarden-browser-animated-fill {
animation: bitwardenfill 200ms ease-in-out 0ms 1;
-webkit-animation: bitwardenfill 200ms ease-in-out 0ms 1;
}
@media (prefers-reduced-motion) {
.com-bitwarden-browser-animated-fill {
animation: none;
-webkit-animation: none;
}
}

View File

@ -0,0 +1,11 @@
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
const autofillOverlayContentService = new AutofillOverlayContentService();
windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService);
windowContext.bitwardenAutofillInit.init();
}
})(window);

View File

@ -0,0 +1,8 @@
import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
windowContext.bitwardenAutofillInit = new AutofillInit();
windowContext.bitwardenAutofillInit.init();
}
})(window);

View File

@ -1,12 +1,18 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UriMatchType } from "@bitwarden/common/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { OverlayCipherData } from "../background/abstractions/overlay.background";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript, { FillScript } from "../models/autofill-script";
import { GenerateFillScriptOptions } from "../services/abstractions/autofill.service";
import { InitAutofillOverlayButtonMessage } from "../overlay/abstractions/autofill-overlay-button";
import { InitAutofillOverlayListMessage } from "../overlay/abstractions/autofill-overlay-list";
import { GenerateFillScriptOptions, PageDetail } from "../services/abstractions/autofill.service";
function createAutofillFieldMock(customFields = {}): AutofillField {
return {
@ -38,6 +44,15 @@ function createAutofillFieldMock(customFields = {}): AutofillField {
};
}
function createPageDetailMock(customFields = {}): PageDetail {
return {
frameId: 0,
tab: createChromeTabMock(),
details: createAutofillPageDetailsMock(),
...customFields,
};
}
function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails {
return {
title: "title",
@ -71,7 +86,7 @@ function createChromeTabMock(customFields = {}): chrome.tabs.Tab {
discarded: false,
autoDiscardable: false,
groupId: 2,
url: "https://tacos.com",
url: "https://jest-testing-website.com",
...customFields,
};
}
@ -84,7 +99,7 @@ function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScr
fillNewPassword: false,
allowTotpAutofill: false,
cipher: mock<CipherView>(),
tabUrl: "https://tacos.com",
tabUrl: "https://jest-testing-website.com",
defaultUriMatch: UriMatchType.Domain,
...customFields,
};
@ -122,10 +137,135 @@ function createAutofillScriptMock(
};
}
const overlayPagesTranslations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
opensInANewWindow: "opensInANewWindow",
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
unlockYourAccount: "unlockYourAccount",
unlockAccount: "unlockAccount",
fillCredentialsFor: "fillCredentialsFor",
partialUsername: "partialUsername",
view: "view",
noItemsToShow: "noItemsToShow",
newItem: "newItem",
addNewVaultItem: "addNewVaultItem",
};
function createInitAutofillOverlayButtonMessageMock(
customFields = {}
): InitAutofillOverlayButtonMessage {
return {
command: "initAutofillOverlayButton",
translations: overlayPagesTranslations,
styleSheetUrl: "https://jest-testing-website.com",
authStatus: AuthenticationStatus.Unlocked,
...customFields,
};
}
function createAutofillOverlayCipherDataMock(index: number, customFields = {}): OverlayCipherData {
return {
id: String(index),
name: `website login ${index}`,
login: { username: `username${index}` },
type: CipherType.Login,
reprompt: CipherRepromptType.None,
favorite: false,
icon: {
imageEnabled: true,
image: "https://jest-testing-website.com/image.png",
fallbackImage: "https://jest-testing-website.com/fallback.png",
icon: "bw-icon",
},
...customFields,
};
}
function createInitAutofillOverlayListMessageMock(
customFields = {}
): InitAutofillOverlayListMessage {
return {
command: "initAutofillOverlayList",
translations: overlayPagesTranslations,
styleSheetUrl: "https://jest-testing-website.com",
theme: "light",
authStatus: AuthenticationStatus.Unlocked,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
icon: {
imageEnabled: true,
image: "https://jest-testing-website.com/image.png",
fallbackImage: "",
icon: "bw-icon",
},
}),
createAutofillOverlayCipherDataMock(2, {
icon: {
imageEnabled: true,
image: "",
fallbackImage: "https://jest-testing-website.com/fallback.png",
icon: "bw-icon",
},
}),
createAutofillOverlayCipherDataMock(3, {
name: "",
login: { username: "" },
icon: { imageEnabled: true, image: "", fallbackImage: "", icon: "bw-icon" },
}),
createAutofillOverlayCipherDataMock(4, {
icon: { imageEnabled: false, image: "", fallbackImage: "", icon: "" },
}),
createAutofillOverlayCipherDataMock(5),
createAutofillOverlayCipherDataMock(6),
createAutofillOverlayCipherDataMock(7),
createAutofillOverlayCipherDataMock(8),
],
...customFields,
};
}
function createFocusedFieldDataMock(customFields = {}) {
return {
focusedFieldRects: {
top: 1,
left: 2,
height: 3,
width: 4,
},
focusedFieldStyles: {
paddingRight: "6px",
paddingLeft: "6px",
},
...customFields,
};
}
function createPortSpyMock(name: string) {
return mock<chrome.runtime.Port>({
name,
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onDisconnect: {
addListener: jest.fn(),
},
postMessage: jest.fn(),
sender: {
tab: createChromeTabMock(),
},
});
}
export {
createAutofillFieldMock,
createPageDetailMock,
createAutofillPageDetailsMock,
createChromeTabMock,
createGenerateFillScriptOptionsMock,
createAutofillScriptMock,
createInitAutofillOverlayButtonMessageMock,
createInitAutofillOverlayListMessageMock,
createFocusedFieldDataMock,
createPortSpyMock,
};

View File

@ -1,5 +1,104 @@
import { mock } from "jest-mock-extended";
function triggerTestFailure() {
expect(true).toBe("Test has failed.");
}
export { triggerTestFailure };
const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout;
function flushPromises() {
return new Promise(function (resolve) {
scheduler(resolve);
});
}
function postWindowMessage(data: any, origin = "https://localhost/") {
globalThis.dispatchEvent(new MessageEvent("message", { data, origin }));
}
function sendExtensionRuntimeMessage(
message: any,
sender?: chrome.runtime.MessageSender,
sendResponse?: CallableFunction
) {
(chrome.runtime.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
(call) => {
const callback = call[0];
callback(
message || {},
sender || mock<chrome.runtime.MessageSender>(),
sendResponse || jest.fn()
);
}
);
}
function sendPortMessage(port: chrome.runtime.Port, message: any) {
(port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
const callback = call[0];
callback(message || {}, port);
});
}
function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) {
(port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
const callback = call[0];
callback(port);
});
}
function triggerWindowOnFocusedChangedEvent(windowId: number) {
(chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
(call) => {
const callback = call[0];
callback(windowId);
}
);
}
function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) {
(chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
(call) => {
const callback = call[0];
callback(activeInfo);
}
);
}
function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) {
(chrome.tabs.onReplaced.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
const callback = call[0];
callback(addedTabId, removedTabId);
});
}
function triggerTabOnUpdatedEvent(
tabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab
) {
(chrome.tabs.onUpdated.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
const callback = call[0];
callback(tabId, changeInfo, tab);
});
}
function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) {
(chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
const callback = call[0];
callback(tabId, removeInfo);
});
}
export {
triggerTestFailure,
flushPromises,
postWindowMessage,
sendExtensionRuntimeMessage,
sendPortMessage,
triggerPortOnDisconnectEvent,
triggerWindowOnFocusedChangedEvent,
triggerTabOnActivatedEvent,
triggerTabOnReplacedEvent,
triggerTabOnUpdatedEvent,
triggerTabOnRemovedEvent,
};

View File

@ -1,4 +1,4 @@
@import "variables.scss";
@import "../shared/styles/variables";
body {
padding: 0;
@ -104,7 +104,7 @@ button.secondary:not(.neutral) {
&:hover {
@include themify($themes) {
background-color: darken(themed("backgroundColor"), 1.5%);
background-color: themed("backgroundOffsetColor");
color: darken(themed("mutedTextColor"), 6%);
}
}

View File

@ -0,0 +1,27 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
type OverlayButtonMessage = { command: string };
type UpdateAuthStatusMessage = OverlayButtonMessage & { authStatus: AuthenticationStatus };
type InitAutofillOverlayButtonMessage = UpdateAuthStatusMessage & {
styleSheetUrl: string;
translations: Record<string, string>;
};
type OverlayButtonWindowMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayButton: ({ message }: { message: InitAutofillOverlayButtonMessage }) => void;
checkAutofillOverlayButtonFocused: () => void;
updateAutofillOverlayButtonAuthStatus: ({
message,
}: {
message: UpdateAuthStatusMessage;
}) => void;
};
export {
UpdateAuthStatusMessage,
InitAutofillOverlayButtonMessage,
OverlayButtonWindowMessageHandlers,
};

View File

@ -0,0 +1,32 @@
type AutofillOverlayIframeExtensionMessage = {
command: string;
styles?: Partial<CSSStyleDeclaration>;
theme?: string;
};
type AutofillOverlayIframeWindowMessageHandlers = {
[key: string]: CallableFunction;
updateAutofillOverlayListHeight: (message: AutofillOverlayIframeExtensionMessage) => void;
};
type AutofillOverlayIframeExtensionMessageParam = {
message: AutofillOverlayIframeExtensionMessage;
};
type BackgroundPortMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayList: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
updateIframePosition: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
updateOverlayHidden: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
};
interface AutofillOverlayIframeService {
initOverlayIframe(initStyles: Partial<CSSStyleDeclaration>, ariaAlert?: string): void;
}
export {
AutofillOverlayIframeExtensionMessage,
AutofillOverlayIframeWindowMessageHandlers,
BackgroundPortMessageHandlers,
AutofillOverlayIframeService,
};

View File

@ -0,0 +1,31 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { OverlayCipherData } from "../../background/abstractions/overlay.background";
type OverlayListMessage = { command: string };
type UpdateOverlayListCiphersMessage = OverlayListMessage & {
ciphers: OverlayCipherData[];
};
type InitAutofillOverlayListMessage = OverlayListMessage & {
authStatus: AuthenticationStatus;
styleSheetUrl: string;
theme: string;
translations: Record<string, string>;
ciphers?: OverlayCipherData[];
};
type OverlayListWindowMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayList: ({ message }: { message: InitAutofillOverlayListMessage }) => void;
checkAutofillOverlayListFocused: () => void;
updateOverlayListCiphers: ({ message }: { message: UpdateOverlayListCiphersMessage }) => void;
focusOverlayList: () => void;
};
export {
UpdateOverlayListCiphersMessage,
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
};

View File

@ -0,0 +1,13 @@
import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button";
import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list";
type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers;
type AutofillOverlayPageElementWindowMessage = {
[key: string]: any;
command: string;
overlayCipherId?: string;
height?: number;
};
export { WindowMessageHandlers, AutofillOverlayPageElementWindowMessage };

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayIframeService initOverlayIframe creates an aria alert element if the ariaAlert param is passed 1`] = `
<div
aria-atomic="true"
aria-live="polite"
role="status"
style="position: absolute !important; top: -9999px !important; left: -9999px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none;"
>
aria alert
</div>
`;
exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's attributes 1`] = `
<iframe
allowtransparency="true"
sandbox="allow-scripts"
src="chrome-extension://id/overlay/list.html"
style="all: initial !important; position: fixed !important; display: block !important; z-index: 2147483647 !important; line-height: 0 !important; overflow: hidden !important; transition: opacity 125ms ease-out 0s !important; visibility: visible !important; clip-path: none !important; pointer-events: auto !important; margin: 0px !important; padding: 0px !important; color-scheme: normal !important; opacity: 0 !important; height: 0px;"
tabindex="-1"
title="title"
/>
`;

View File

@ -0,0 +1,19 @@
import AutofillOverlayButtonIframe from "./autofill-overlay-button-iframe";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
describe("AutofillOverlayButtonIframe", () => {
window.customElements.define("autofill-overlay-button-iframe", AutofillOverlayButtonIframe);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-button-iframe></autofill-overlay-button-iframe>";
const iframe = document.querySelector("autofill-overlay-button-iframe");
expect(iframe).toBeInstanceOf(AutofillOverlayButtonIframe);
expect(iframe).toBeInstanceOf(AutofillOverlayIframeElement);
});
});

View File

@ -0,0 +1,20 @@
import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
class AutofillOverlayButtonIframe extends AutofillOverlayIframeElement {
constructor() {
super(
"overlay/button.html",
AutofillOverlayPort.Button,
{
background: "transparent",
border: "none",
},
chrome.i18n.getMessage("bitwardenOverlayButton"),
chrome.i18n.getMessage("bitwardenOverlayMenuAvailable")
);
}
}
export default AutofillOverlayButtonIframe;

View File

@ -0,0 +1,32 @@
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service";
jest.mock("./autofill-overlay-iframe.service");
describe("AutofillOverlayIframeElement", () => {
window.customElements.define("autofill-overlay-iframe", AutofillOverlayIframeElement);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the HTMLElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-iframe></autofill-overlay-iframe>";
const iframe = document.querySelector("autofill-overlay-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
});
it("attaches a closed shadow DOM", () => {
document.body.innerHTML = "<autofill-overlay-iframe></autofill-overlay-iframe>";
const iframe = document.querySelector("autofill-overlay-iframe");
expect(iframe.shadowRoot).toBeNull();
});
it("instantiates the autofill overlay iframe service for each attached custom element", () => {
expect(AutofillOverlayIframeService).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,23 @@
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service";
class AutofillOverlayIframeElement extends HTMLElement {
constructor(
iframePath: string,
portName: string,
initStyles: Partial<CSSStyleDeclaration>,
iframeTitle: string,
ariaAlert?: string
) {
super();
const shadow: ShadowRoot = this.attachShadow({ mode: "closed" });
const autofillOverlayIframeService = new AutofillOverlayIframeService(
iframePath,
portName,
shadow
);
autofillOverlayIframeService.initOverlayIframe(initStyles, iframeTitle, ariaAlert);
}
}
export default AutofillOverlayIframeElement;

View File

@ -0,0 +1,406 @@
import { EVENTS } from "../../constants";
import { createPortSpyMock } from "../../jest/autofill-mocks";
import {
flushPromises,
sendPortMessage,
triggerPortOnDisconnectEvent,
} from "../../jest/testing-utils";
import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum";
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service";
describe("AutofillOverlayIframeService", () => {
const iframePath = "overlay/list.html";
let autofillOverlayIframeService: AutofillOverlayIframeService;
let portSpy: chrome.runtime.Port;
let shadowAppendSpy: jest.SpyInstance;
let handlePortDisconnectSpy: jest.SpyInstance;
let handlePortMessageSpy: jest.SpyInstance;
let handleWindowMessageSpy: jest.SpyInstance;
beforeEach(() => {
const shadow = document.createElement("div").attachShadow({ mode: "open" });
autofillOverlayIframeService = new AutofillOverlayIframeService(
iframePath,
AutofillOverlayPort.Button,
shadow
);
shadowAppendSpy = jest.spyOn(shadow, "appendChild");
handlePortDisconnectSpy = jest.spyOn(
autofillOverlayIframeService as any,
"handlePortDisconnect"
);
handlePortMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handlePortMessage");
handleWindowMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handleWindowMessage");
chrome.runtime.connect = jest.fn((connectInfo: chrome.runtime.ConnectInfo) =>
createPortSpyMock(connectInfo.name)
) as unknown as typeof chrome.runtime.connect;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initOverlayIframe", () => {
it("sets up the iframe's attributes", () => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title");
expect(autofillOverlayIframeService["iframe"]).toMatchSnapshot();
});
it("appends the iframe to the shadowDom", () => {
jest.spyOn(autofillOverlayIframeService["shadow"], "appendChild");
autofillOverlayIframeService.initOverlayIframe({}, "title");
expect(autofillOverlayIframeService["shadow"].appendChild).toBeCalledWith(
autofillOverlayIframeService["iframe"]
);
});
it("creates an aria alert element if the ariaAlert param is passed", () => {
const ariaAlert = "aria alert";
jest.spyOn(autofillOverlayIframeService as any, "createAriaAlertElement");
autofillOverlayIframeService.initOverlayIframe({}, "title", ariaAlert);
expect(autofillOverlayIframeService["createAriaAlertElement"]).toBeCalledWith(ariaAlert);
expect(autofillOverlayIframeService["ariaAlertElement"]).toMatchSnapshot();
});
describe("on load of the iframe source", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
});
it("sets up and connects the port message listener to the extension background", () => {
jest.spyOn(globalThis, "addEventListener");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillOverlayIframeService["port"];
expect(chrome.runtime.connect).toBeCalledWith({ name: AutofillOverlayPort.Button });
expect(portSpy.onDisconnect.addListener).toBeCalledWith(handlePortDisconnectSpy);
expect(portSpy.onMessage.addListener).toBeCalledWith(handlePortMessageSpy);
expect(globalThis.addEventListener).toBeCalledWith(EVENTS.MESSAGE, handleWindowMessageSpy);
});
it("skips announcing the aria alert if the aria alert element is not populated", () => {
jest.spyOn(globalThis, "setTimeout");
autofillOverlayIframeService["ariaAlertElement"] = undefined;
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
expect(globalThis.setTimeout).not.toBeCalled();
});
it("announces the aria alert if the aria alert element is populated", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "setTimeout");
autofillOverlayIframeService["ariaAlertElement"] = document.createElement("div");
autofillOverlayIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 2000);
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
expect(globalThis.setTimeout).toBeCalled();
jest.advanceTimersByTime(2000);
expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]);
});
});
});
describe("event listeners", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
Object.defineProperty(autofillOverlayIframeService["iframe"], "contentWindow", {
value: {
postMessage: jest.fn(),
},
writable: true,
});
jest.spyOn(autofillOverlayIframeService["iframe"].contentWindow, "postMessage");
portSpy = autofillOverlayIframeService["port"];
});
describe("handlePortDisconnect", () => {
it("ignores ports that do not have the correct port name", () => {
portSpy.name = "wrong-port-name";
triggerPortOnDisconnectEvent(portSpy);
expect(autofillOverlayIframeService["port"]).not.toBeNull();
});
it("resets the iframe element's opacity, height, and display styles", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0");
expect(autofillOverlayIframeService["iframe"].style.height).toBe("0px");
expect(autofillOverlayIframeService["iframe"].style.display).toBe("block");
});
it("removes the global message listener", () => {
jest.spyOn(globalThis, "removeEventListener");
triggerPortOnDisconnectEvent(portSpy);
expect(globalThis.removeEventListener).toBeCalledWith(
EVENTS.MESSAGE,
handleWindowMessageSpy
);
});
it("removes the port's onMessage listener", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.onMessage.removeListener).toBeCalledWith(handlePortMessageSpy);
});
it("removes the port's onDisconnect listener", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.onDisconnect.removeListener).toBeCalledWith(handlePortDisconnectSpy);
});
it("disconnects the port", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.disconnect).toBeCalled();
expect(autofillOverlayIframeService["port"]).toBeNull();
});
});
describe("handlePortMessage", () => {
it("ignores port messages that do not correlate to the correct port name", () => {
portSpy.name = "wrong-port-name";
sendPortMessage(portSpy, {});
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled();
});
it("passes on the message to the iframe if the message is not registered with the message handlers", () => {
const message = { command: "unregisteredMessage" };
sendPortMessage(portSpy, message);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
message,
"*"
);
});
it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => {
jest.spyOn(autofillOverlayIframeService as any, "updateIframePosition");
sendPortMessage(portSpy, { command: "updateIframePosition" });
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled();
});
describe("initializing the overlay list", () => {
let updateElementStylesSpy: jest.SpyInstance;
beforeEach(() => {
updateElementStylesSpy = jest.spyOn(
autofillOverlayIframeService as any,
"updateElementStyles"
);
});
it("passed the message on to the iframe element", () => {
const message = {
command: "initAutofillOverlayList",
theme: "theme_light",
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).not.toBeCalled();
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
message,
"*"
);
});
it("updates the border to match the `dark` theme", () => {
const message = {
command: "initAutofillOverlayList",
theme: "theme_dark",
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], {
borderColor: "#4c525f",
});
});
it("updates the border to match the `nord` theme", () => {
const message = {
command: "initAutofillOverlayList",
theme: "theme_nord",
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], {
borderColor: "#2E3440",
});
});
it("updates the border to match the `solarizedDark` theme", () => {
const message = {
command: "initAutofillOverlayList",
theme: "theme_solarizedDark",
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], {
borderColor: "#073642",
});
});
});
describe("updating the iframe's position", () => {
beforeEach(() => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
});
it("ignores updating the iframe position if the document does not have focus", () => {
jest.spyOn(autofillOverlayIframeService as any, "updateElementStyles");
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles: { top: 100, left: 100 },
});
expect(autofillOverlayIframeService["updateElementStyles"]).not.toBeCalled();
});
it("updates the iframe position if the document has focus", () => {
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
expect(autofillOverlayIframeService["iframe"].style.top).toBe(styles.top);
expect(autofillOverlayIframeService["iframe"].style.left).toBe(styles.left);
});
it("fades the iframe element in after positioning the element", () => {
jest.useFakeTimers();
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0");
jest.advanceTimersByTime(10);
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("1");
});
it("announces the opening of the iframe using an aria alert", () => {
jest.useFakeTimers();
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
jest.advanceTimersByTime(2000);
expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]);
});
});
it("updates the visibility of the iframe", () => {
sendPortMessage(portSpy, {
command: "updateOverlayHidden",
styles: { display: "none" },
});
expect(autofillOverlayIframeService["iframe"].style.display).toBe("none");
});
});
describe("handleWindowMessage", () => {
it("ignores window messages when the port is not set", () => {
autofillOverlayIframeService["port"] = null;
globalThis.dispatchEvent(new MessageEvent("message", { data: {} }));
expect(autofillOverlayIframeService["port"]).toBeNull();
});
it("ignores window messages whose source is not the iframe's content window", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: {},
source: window,
})
);
expect(portSpy.postMessage).not.toBeCalled();
});
it("ignores window messages whose origin is not from the extension origin", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: {},
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "https://www.google.com",
})
);
expect(portSpy.postMessage).not.toBeCalled();
});
it("passes the window message from an iframe element to the background port", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "not-a-handled-command" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
})
);
expect(portSpy.postMessage).toBeCalledWith({ command: "not-a-handled-command" });
});
it("updates the overlay list height", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "updateAutofillOverlayListHeight", styles: { height: "300px" } },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
})
);
expect(autofillOverlayIframeService["iframe"].style.height).toBe("300px");
});
});
});
describe("mutation observer", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
});
it("reverts any styles changes made directly to the iframe", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["iframe"].style.visibility = "hidden";
await flushPromises();
expect(autofillOverlayIframeService["iframe"].style.visibility).toBe("visible");
});
});
});

View File

@ -0,0 +1,315 @@
import { EVENTS } from "../../constants";
import { setElementStyles } from "../../utils/utils";
import {
BackgroundPortMessageHandlers,
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
AutofillOverlayIframeExtensionMessage,
AutofillOverlayIframeWindowMessageHandlers,
} from "../abstractions/autofill-overlay-iframe.service";
class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterface {
private port: chrome.runtime.Port | null = null;
private extensionOriginsSet: Set<string>;
private iframeMutationObserver: MutationObserver;
private iframe: HTMLIFrameElement;
private ariaAlertElement: HTMLDivElement;
private ariaAlertTimeout: NodeJS.Timeout;
private iframeStyles: Partial<CSSStyleDeclaration> = {
all: "initial",
position: "fixed",
display: "block",
zIndex: "2147483647",
lineHeight: "0",
overflow: "hidden",
transition: "opacity 125ms ease-out 0s",
visibility: "visible",
clipPath: "none",
pointerEvents: "auto",
margin: "0",
padding: "0",
colorScheme: "normal",
opacity: "0",
};
private readonly windowMessageHandlers: AutofillOverlayIframeWindowMessageHandlers = {
updateAutofillOverlayListHeight: (message) =>
this.updateElementStyles(this.iframe, message.styles),
};
private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = {
initAutofillOverlayList: ({ message }) => this.initAutofillOverlayList(message),
updateIframePosition: ({ message }) => this.updateIframePosition(message.styles),
updateOverlayHidden: ({ message }) => this.updateElementStyles(this.iframe, message.styles),
};
constructor(private iframePath: string, private portName: string, private shadow: ShadowRoot) {
this.extensionOriginsSet = new Set([
chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase
"null",
]);
this.iframeMutationObserver = new MutationObserver(this.handleMutations);
}
/**
* Handles initialization of the iframe which includes applying initial styles
* to the iframe, setting the source, and adding listener that connects the
* iframe to the background script each time it loads. Can conditionally
* create an aria alert element to announce to screen readers when the iframe
* is loaded. The end result is append to the shadowDOM of the custom element
* that is declared.
*
*
* @param initStyles - Initial styles to apply to the iframe
* @param iframeTitle - Title to apply to the iframe
* @param ariaAlert - Text to announce to screen readers when the iframe is loaded
*/
initOverlayIframe(
initStyles: Partial<CSSStyleDeclaration>,
iframeTitle: string,
ariaAlert?: string
) {
this.iframe = globalThis.document.createElement("iframe");
this.iframe.src = chrome.runtime.getURL(this.iframePath);
this.updateElementStyles(this.iframe, { ...this.iframeStyles, ...initStyles });
this.iframe.tabIndex = -1;
this.iframe.setAttribute("title", iframeTitle);
this.iframe.setAttribute("sandbox", "allow-scripts");
this.iframe.setAttribute("allowtransparency", "true");
this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener);
if (ariaAlert) {
this.createAriaAlertElement(ariaAlert);
}
this.shadow.appendChild(this.iframe);
}
/**
* Creates an aria alert element that is used to announce to screen readers
* when the iframe is loaded.
*
* @param ariaAlertText - Text to announce to screen readers when the iframe is loaded
*/
private createAriaAlertElement(ariaAlertText: string) {
this.ariaAlertElement = globalThis.document.createElement("div");
this.ariaAlertElement.setAttribute("role", "status");
this.ariaAlertElement.setAttribute("aria-live", "polite");
this.ariaAlertElement.setAttribute("aria-atomic", "true");
this.updateElementStyles(this.ariaAlertElement, {
position: "absolute",
top: "-9999px",
left: "-9999px",
width: "1px",
height: "1px",
overflow: "hidden",
opacity: "0",
pointerEvents: "none",
});
this.ariaAlertElement.textContent = ariaAlertText;
}
/**
* Sets up the port message listener to the extension background script. This
* listener is used to communicate between the iframe and the background script.
* This also facilitates announcing to screen readers when the iframe is loaded.
*/
private setupPortMessageListener = () => {
this.port = chrome.runtime.connect({ name: this.portName });
this.port.onDisconnect.addListener(this.handlePortDisconnect);
this.port.onMessage.addListener(this.handlePortMessage);
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
this.announceAriaAlert();
};
/**
* Announces the aria alert element to screen readers when the iframe is loaded.
*/
private announceAriaAlert() {
if (!this.ariaAlertElement) {
return;
}
this.ariaAlertElement.remove();
if (this.ariaAlertTimeout) {
clearTimeout(this.ariaAlertTimeout);
}
this.ariaAlertTimeout = setTimeout(() => this.shadow.appendChild(this.ariaAlertElement), 2000);
}
/**
* Handles disconnecting the port message listener from the extension background
* script. This also removes the listener that facilitates announcing to screen
* readers when the iframe is loaded.
*
* @param port - The port that is disconnected
*/
private handlePortDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== this.portName) {
return;
}
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
globalThis.removeEventListener("message", this.handleWindowMessage);
this.port.onMessage.removeListener(this.handlePortMessage);
this.port.onDisconnect.removeListener(this.handlePortDisconnect);
this.port.disconnect();
this.port = null;
};
/**
* Handles messages sent from the extension background script to the iframe.
* Triggers behavior within the iframe as well as on the custom element that
* contains the iframe element.
*
* @param message
* @param port
*/
private handlePortMessage = (
message: AutofillOverlayIframeExtensionMessage,
port: chrome.runtime.Port
) => {
if (port.name !== this.portName) {
return;
}
if (this.backgroundPortMessageHandlers[message.command]) {
this.backgroundPortMessageHandlers[message.command]({ message, port });
return;
}
this.iframe.contentWindow?.postMessage(message, "*");
};
/**
* Handles messages sent from the iframe to the extension background script.
* Will adjust the border element to fit the user's set theme.
*
* @param message - The message sent from the iframe
*/
private initAutofillOverlayList(message: AutofillOverlayIframeExtensionMessage) {
const { theme } = message;
let borderColor: string;
if (theme === "theme_dark") {
borderColor = "#4c525f";
}
if (theme === "theme_nord") {
borderColor = "#2E3440";
}
if (theme === "theme_solarizedDark") {
borderColor = "#073642";
}
if (borderColor) {
this.updateElementStyles(this.iframe, { borderColor });
}
this.iframe.contentWindow?.postMessage(message, "*");
}
/**
* Updates the position of the iframe element. Will also announce
* to screen readers that the iframe is open.
*
* @param position - The position styles to apply to the iframe
*/
private updateIframePosition(position: Partial<CSSStyleDeclaration>) {
if (!globalThis.document.hasFocus()) {
return;
}
this.updateElementStyles(this.iframe, position);
setTimeout(() => this.updateElementStyles(this.iframe, { opacity: "1" }), 0);
this.announceAriaAlert();
}
/**
* Handles messages sent from the iframe. If the message does not have a
* specified handler set, it passes the message to the background script.
*
* @param event - The message event
*/
private handleWindowMessage = (event: MessageEvent) => {
if (
!this.port ||
event.source !== this.iframe.contentWindow ||
!this.isFromExtensionOrigin(event.origin.toLowerCase())
) {
return;
}
const message = event.data;
if (this.windowMessageHandlers[message.command]) {
this.windowMessageHandlers[message.command](message);
return;
}
this.port.postMessage(event.data);
};
/**
* Accepts an element and updates the styles for that element. This method
* will also unobserve the element if it is the iframe element. This is
* done to ensure that we do not trigger the mutation observer when we
* update the styles for the iframe.
*
* @param customElement - The element to update the styles for
* @param styles - The styles to apply to the element
*/
private updateElementStyles(customElement: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
if (!customElement) {
return;
}
this.unobserveIframe();
setElementStyles(customElement, styles, true);
this.iframeStyles = { ...this.iframeStyles, ...styles };
this.observeIframe();
}
/**
* Chrome returns null for any sandboxed iframe sources.
* Firefox references the extension URI as its origin.
* Any other origin value is a security risk.
*
* @param messageOrigin - The origin of the window message
*/
private isFromExtensionOrigin(messageOrigin: string): boolean {
return this.extensionOriginsSet.has(messageOrigin);
}
/**
* Handles mutations to the iframe element. The ensures that the iframe
* element's styles are not modified by a third party source.
*
* @param mutations - The mutations to the iframe element
*/
private handleMutations = (mutations: MutationRecord[]) => {
for (let index = 0; index < mutations.length; index++) {
const mutation = mutations[index];
if (mutation.type !== "attributes" || mutation.attributeName !== "style") {
continue;
}
this.iframe.removeAttribute("style");
this.updateElementStyles(this.iframe, this.iframeStyles);
}
};
/**
* Observes the iframe element for mutations to its style attribute.
*/
private observeIframe() {
this.iframeMutationObserver.observe(this.iframe, { attributes: true });
}
/**
* Unobserves the iframe element for mutations to its style attribute.
*/
private unobserveIframe() {
this.iframeMutationObserver.disconnect();
}
}
export default AutofillOverlayIframeService;

View File

@ -0,0 +1,19 @@
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
import AutofillOverlayListIframe from "./autofill-overlay-list-iframe";
describe("AutofillOverlayListIframe", () => {
window.customElements.define("autofill-overlay-list-iframe", AutofillOverlayListIframe);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-list-iframe></autofill-overlay-list-iframe>";
const iframe = document.querySelector("autofill-overlay-list-iframe");
expect(iframe).toBeInstanceOf(AutofillOverlayListIframe);
expect(iframe).toBeInstanceOf(AutofillOverlayIframeElement);
});
});

View File

@ -0,0 +1,25 @@
import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
class AutofillOverlayListIframe extends AutofillOverlayIframeElement {
constructor() {
super(
"overlay/list.html",
AutofillOverlayPort.List,
{
height: "0px",
minWidth: "250px",
maxHeight: "180px",
boxShadow: "rgba(0, 0, 0, 0.1) 2px 4px 6px 0px",
borderRadius: "4px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "rgb(206, 212, 220)",
},
chrome.i18n.getMessage("bitwardenVault")
);
}
}
export default AutofillOverlayListIframe;

View File

@ -0,0 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayButton initAutofillOverlayButton creates the button element with the locked icon when the user's auth status is not Unlocked 1`] = `
<button
aria-label="toggleBitwardenVaultOverlay"
class="overlay-button"
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
class="overlay-button-svg-icon logo-locked-icon"
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
fill="#175DDC"
/>
<path
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
fill="#fff"
/>
<circle
cx="12.889"
cy="12.889"
fill="#F8F9FA"
r="4.889"
/>
<path
d="M11.26 11.717h2.37v-.848c0-.313-.116-.58-.348-.8a1.17 1.17 0 0 0-.838-.332c-.327 0-.606.11-.838.332a1.066 1.066 0 0 0-.347.8v.848Zm3.851.424v2.546a.4.4 0 0 1-.13.3.44.44 0 0 1-.314.124h-4.445a.44.44 0 0 1-.315-.124.4.4 0 0 1-.13-.3V12.14a.4.4 0 0 1 .13-.3.44.44 0 0 1 .315-.124h.148v-.848c0-.542.204-1.008.612-1.397a2.044 2.044 0 0 1 1.462-.583c.568 0 1.056.194 1.463.583.408.39.611.855.611 1.397v.848h.149a.44.44 0 0 1 .315.124.4.4 0 0 1 .13.3Z"
fill="#555"
/>
</g>
<defs>
<clippath
id="a"
>
<rect
fill="#fff"
height="16"
rx="2"
width="16"
/>
</clippath>
</defs>
</svg>
</button>
`;
exports[`AutofillOverlayButton initAutofillOverlayButton creates the button element with the normal icon when the user's auth status is Unlocked 1`] = `
<button
aria-label="toggleBitwardenVaultOverlay"
class="overlay-button"
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
class="overlay-button-svg-icon logo-icon"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
fill="#175DDC"
/>
<path
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
fill="#fff"
/>
</svg>
</button>
`;

View File

@ -0,0 +1,92 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { createInitAutofillOverlayButtonMessageMock } from "../../../jest/autofill-mocks";
import { postWindowMessage } from "../../../jest/testing-utils";
import AutofillOverlayButton from "./autofill-overlay-button";
describe("AutofillOverlayButton", () => {
globalThis.customElements.define("autofill-overlay-button", AutofillOverlayButton);
let autofillOverlayButton: AutofillOverlayButton;
beforeEach(() => {
document.body.innerHTML = `<autofill-overlay-button></autofill-overlay-button>`;
autofillOverlayButton = document.querySelector("autofill-overlay-button");
autofillOverlayButton["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initAutofillOverlayButton", () => {
it("creates the button element with the locked icon when the user's auth status is not Unlocked", () => {
postWindowMessage(
createInitAutofillOverlayButtonMessageMock({ authStatus: AuthenticationStatus.Locked })
);
expect(autofillOverlayButton["buttonElement"]).toMatchSnapshot();
expect(autofillOverlayButton["buttonElement"].querySelector("svg")).toBe(
autofillOverlayButton["logoLockedIconElement"]
);
});
it("creates the button element with the normal icon when the user's auth status is Unlocked ", () => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
expect(autofillOverlayButton["buttonElement"]).toMatchSnapshot();
expect(autofillOverlayButton["buttonElement"].querySelector("svg")).toBe(
autofillOverlayButton["logoIconElement"]
);
});
it("posts a message to the background indicating that the icon was clicked", () => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
autofillOverlayButton["buttonElement"].click();
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "overlayButtonClicked" },
"https://localhost/"
);
});
});
describe("global event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
});
it("does not post a message to close the autofill overlay if the element is focused during the focus check", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillOverlayButtonFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to close the autofill overlay if the element is not focused during the focus check", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
postWindowMessage({ command: "checkAutofillOverlayButtonFocused" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "closeAutofillOverlay" },
"https://localhost/"
);
});
it("updates the user's auth status", () => {
autofillOverlayButton["authStatus"] = AuthenticationStatus.Locked;
postWindowMessage({
command: "updateAutofillOverlayButtonAuthStatus",
authStatus: AuthenticationStatus.Unlocked,
});
expect(autofillOverlayButton["authStatus"]).toBe(AuthenticationStatus.Unlocked);
});
});
});

View File

@ -0,0 +1,108 @@
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "../../../constants";
import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import {
InitAutofillOverlayButtonMessage,
OverlayButtonWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-button";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element";
class AutofillOverlayButton extends AutofillOverlayPageElement {
private authStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
private readonly buttonElement: HTMLButtonElement;
private readonly logoIconElement: HTMLElement;
private readonly logoLockedIconElement: HTMLElement;
private readonly overlayButtonWindowMessageHandlers: OverlayButtonWindowMessageHandlers = {
initAutofillOverlayButton: ({ message }) => this.initAutofillOverlayButton(message),
checkAutofillOverlayButtonFocused: () => this.checkButtonFocused(),
updateAutofillOverlayButtonAuthStatus: ({ message }) =>
this.updateAuthStatus(message.authStatus),
};
constructor() {
super();
this.buttonElement = globalThis.document.createElement("button");
this.setupGlobalListeners(this.overlayButtonWindowMessageHandlers);
this.logoIconElement = buildSvgDomElement(logoIcon);
this.logoIconElement.classList.add("overlay-button-svg-icon", "logo-icon");
this.logoLockedIconElement = buildSvgDomElement(logoLockedIcon);
this.logoLockedIconElement.classList.add("overlay-button-svg-icon", "logo-locked-icon");
}
/**
* Initializes the overlay button. Facilitates ensuring that the page
* is set up with the expected styles and translations.
*
* @param authStatus - The authentication status of the user
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
* @param translations - The translations to apply to the page
* @private
*/
private async initAutofillOverlayButton({
authStatus,
styleSheetUrl,
translations,
}: InitAutofillOverlayButtonMessage) {
const linkElement = this.initOverlayPage("button", styleSheetUrl, translations);
this.buttonElement.tabIndex = -1;
this.buttonElement.type = "button";
this.buttonElement.classList.add("overlay-button");
this.buttonElement.setAttribute(
"aria-label",
this.getTranslation("toggleBitwardenVaultOverlay")
);
this.buttonElement.addEventListener(EVENTS.CLICK, this.handleButtonElementClick);
this.updateAuthStatus(authStatus);
this.shadowDom.append(linkElement, this.buttonElement);
}
/**
* Updates the authentication status of the user. This will update the icon
* displayed on the button.
*
* @param authStatus - The authentication status of the user
*/
private updateAuthStatus(authStatus: AuthenticationStatus) {
this.authStatus = authStatus;
this.buttonElement.innerHTML = "";
const iconElement =
this.authStatus === AuthenticationStatus.Unlocked
? this.logoIconElement
: this.logoLockedIconElement;
this.buttonElement.append(iconElement);
}
/**
* Handles a click event on the button element. Posts a message to the
* parent window indicating that the button was clicked.
*/
private handleButtonElementClick = () => {
this.postMessageToParent({ command: "overlayButtonClicked" });
};
/**
* Checks if the button is focused. If it is not, then it posts a message
* to the parent window indicating that the overlay should be closed.
*/
private checkButtonFocused() {
if (globalThis.document.hasFocus()) {
return;
}
this.postMessageToParent({ command: "closeAutofillOverlay" });
}
}
export default AutofillOverlayButton;

View File

@ -0,0 +1,9 @@
import { AutofillOverlayElement } from "../../../utils/autofill-overlay.enum";
import AutofillOverlayButton from "./autofill-overlay-button";
require("./button.scss");
(function () {
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillOverlayButton);
})();

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bitwarden overlay button</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="normal" />
</head>
<body>
<autofill-overlay-button></autofill-overlay-button>
</body>
</html>

View File

@ -0,0 +1,36 @@
@import "../../../shared/styles/variables";
* {
box-sizing: border-box;
}
body {
width: 100%;
min-width: 100vw;
height: 100%;
min-height: 100vh;
padding: 0;
margin: 0;
background: transparent;
overflow: hidden;
}
autofill-overlay-button {
width: 100%;
height: auto;
}
.overlay-button {
display: block;
width: 100%;
padding: 0;
margin: auto;
border: none;
background: transparent;
cursor: pointer;
.overlay-button-svg-icon {
display: block;
width: 100%;
height: auto;
}
}

View File

@ -0,0 +1,542 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayList initAutofillOverlayList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = `
<div
aria-modal="true"
class="overlay-list-container light"
role="dialog"
>
<ul
class="overlay-actions-list"
role="list"
>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username1"
aria-label="fillCredentialsFor website login 1"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 1"
>
website login 1
</span>
<span
class="cipher-user-login"
title="username1"
>
username1
</span>
</span>
</button>
<button
aria-label="view website login 1, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username2"
aria-label="fillCredentialsFor website login 2"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 2"
>
website login 2
</span>
<span
class="cipher-user-login"
title="username2"
>
username2
</span>
</span>
</button>
<button
aria-label="view website login 2, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, "
aria-label="fillCredentialsFor "
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
/>
</button>
<button
aria-label="view , opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username4"
aria-label="fillCredentialsFor website login 4"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
>
<svg
aria-hidden="true"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M18.026 17.842c-1.418 1.739-3.517 2.84-5.86 2.84a7.364 7.364 0 0 1-3.431-.848l.062-.15.062-.151.063-.157c.081-.203.17-.426.275-.646.133-.28.275-.522.426-.68.026-.028.101-.075.275-.115.165-.037.376-.059.629-.073.138-.008.288-.014.447-.02.399-.016.847-.034 1.266-.092.314-.044.566-.131.755-.271a.884.884 0 0 0 .352-.555c.037-.2.008-.392-.03-.543-.035-.135-.084-.264-.12-.355l-.01-.03a4.26 4.26 0 0 0-.145-.33c-.126-.264-.237-.497-.288-1.085-.03-.344.09-.73.251-1.138l.089-.22c.05-.123.1-.247.14-.355.064-.171.129-.375.129-.566a1.51 1.51 0 0 0-.134-.569 2.573 2.573 0 0 0-.319-.547c-.246-.323-.635-.669-1.093-.669-.44 0-1.006.169-1.487.368-.246.102-.48.216-.68.33-.192.111-.372.235-.492.359-.93.96-1.48 1.239-1.81 1.258-.277.017-.478-.15-.736-.525a9.738 9.738 0 0 1-.19-.29l-.006-.01a11.568 11.568 0 0 0-.198-.305 2.76 2.76 0 0 0-.521-.6 1.39 1.39 0 0 0-1.088-.314 8.302 8.302 0 0 1 1.987-3.936c.055.342.146.626.272.856.23.42.561.64.926.716.406.086.857-.061 1.26-.216.125-.047.248-.097.372-.147.309-.125.618-.25.947-.341.26-.072.581-.057.959.012.264.049.529.118.8.19l.36.091c.379.094.782.178 1.135.148.374-.032.733-.197.934-.623a.874.874 0 0 0 .024-.752c-.087-.197-.24-.355-.35-.47-.26-.267-.412-.427-.412-.685 0-.125.037-.2.09-.263a.982.982 0 0 1 .303-.211c.059-.03.119-.058.183-.089l.036-.016a3.79 3.79 0 0 0 .236-.118c.047-.026.098-.056.148-.093 1.936.747 3.51 2.287 4.368 4.249a7.739 7.739 0 0 0-.031-.004c-.38-.047-.738-.056-1.063.061-.34.123-.603.368-.817.74-.122.211-.284.43-.463.67l-.095.129c-.207.281-.431.595-.58.92-.15.326-.245.705-.142 1.103.104.397.387.738.837 1.036.099.065.225.112.314.145l.02.008c.108.04.195.074.268.117.07.042.106.08.124.114.017.03.037.087.022.206-.047.376-.069.73-.052 1.034.017.292.071.59.218.809.118.174.12.421.108.786v.01a2.46 2.46 0 0 0 .021.518.809.809 0 0 0 .15.35Zm1.357.059a9.654 9.654 0 0 0 1.62-5.386c0-5.155-3.957-9.334-8.836-9.334-4.88 0-8.836 4.179-8.836 9.334 0 3.495 1.82 6.543 4.513 8.142v.093h.161a8.426 8.426 0 0 0 4.162 1.098c2.953 0 5.568-1.53 7.172-3.882a.569.569 0 0 0 .048-.062l-.004-.003ZM8.152 19.495a43.345 43.345 0 0 1 .098-.238l.057-.142c.082-.205.182-.455.297-.698.143-.301.323-.624.552-.864.163-.172.392-.254.602-.302.219-.05.473-.073.732-.088.162-.01.328-.016.495-.023.386-.015.782-.03 1.168-.084.255-.036.392-.099.461-.15.06-.045.076-.084.083-.12a.534.534 0 0 0-.02-.223 2.552 2.552 0 0 0-.095-.278l-.01-.027a3.128 3.128 0 0 0-.104-.232c-.134-.282-.31-.65-.374-1.381-.046-.533.138-1.063.3-1.472.035-.09.069-.172.1-.249.046-.11.086-.21.123-.31.062-.169.083-.264.083-.312a.812.812 0 0 0-.076-.283 1.867 1.867 0 0 0-.23-.394c-.21-.274-.428-.408-.577-.408-.315 0-.788.13-1.246.32a5.292 5.292 0 0 0-.603.293 1.727 1.727 0 0 0-.347.244c-.936.968-1.641 1.421-2.235 1.457-.646.04-1.036-.413-1.31-.813-.07-.103-.139-.21-.203-.311l-.005-.007c-.064-.101-.125-.197-.188-.29a2.098 2.098 0 0 0-.387-.453.748.748 0 0 0-.436-.18c-.1-.006-.22.005-.365.046a8.707 8.707 0 0 0-.056.992c0 2.957 1.488 5.547 3.716 6.98Zm10.362-2.316.003-.192.002-.046c.01-.305.026-.786-.232-1.169-.036-.054-.082-.189-.096-.444-.014-.243.003-.55.047-.9a1.051 1.051 0 0 0-.105-.649.987.987 0 0 0-.374-.374 2.285 2.285 0 0 0-.367-.166h-.003a1.243 1.243 0 0 1-.205-.088c-.369-.244-.505-.46-.549-.629-.044-.168-.015-.364.099-.61.115-.25.297-.511.507-.796l.089-.12c.178-.239.368-.495.512-.745.152-.263.302-.382.466-.441.18-.065.416-.073.77-.03.142.018.275.04.397.063.274.837.423 1.736.423 2.671a8.45 8.45 0 0 1-1.384 4.665Zm-4.632-12.63a7.362 7.362 0 0 0-1.715-.201c-1.89 0-3.621.716-4.965 1.905.025.54.12.887.24 1.105.13.238.295.34.482.38.2.042.484-.027.905-.188l.328-.13c.32-.13.681-.275 1.048-.377.398-.111.833-.075 1.24 0 .289.053.59.132.871.205l.326.084c.383.094.694.151.932.13.216-.017.326-.092.395-.237.039-.083.027-.114.014-.144-.027-.062-.088-.136-.212-.264l-.043-.044c-.218-.222-.567-.578-.567-1.142 0-.305.101-.547.262-.734.137-.159.308-.267.46-.347Z"
fill="#777"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 4"
>
website login 4
</span>
<span
class="cipher-user-login"
title="username4"
>
username4
</span>
</span>
</button>
<button
aria-label="view website login 4, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username5"
aria-label="fillCredentialsFor website login 5"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 5"
>
website login 5
</span>
<span
class="cipher-user-login"
title="username5"
>
username5
</span>
</span>
</button>
<button
aria-label="view website login 5, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username6"
aria-label="fillCredentialsFor website login 6"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 6"
>
website login 6
</span>
<span
class="cipher-user-login"
title="username6"
>
username6
</span>
</span>
</button>
<button
aria-label="view website login 6, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
</ul>
</div>
`;
exports[`AutofillOverlayList initAutofillOverlayList the locked overlay for an unauthenticated user creates the views for the locked overlay 1`] = `
<div
aria-modal="true"
class="overlay-list-container light"
role="dialog"
>
<div
class="locked-overlay overlay-list-message"
id="locked-overlay-description"
>
unlockYourAccount
</div>
<div
class="overlay-list-button-container"
>
<button
aria-label="unlockAccount, opensInANewWindow"
class="unlock-button overlay-list-button"
id="unlock-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
viewBox="0 0 17 17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M8.799 11.633a.68.68 0 0 0-.639.422.695.695 0 0 0-.054.264.682.682 0 0 0 .374.6v1.13a.345.345 0 1 0 .693 0v-1.17a.68.68 0 0 0 .315-.56.695.695 0 0 0-.204-.486.682.682 0 0 0-.485-.2Zm4.554-4.657h-7.11a.438.438 0 0 1-.406-.26A3.81 3.81 0 0 1 5.584 4.3c.112-.435.312-.842.588-1.195A3.196 3.196 0 0 1 7.19 2.25a3.468 3.468 0 0 1 3.225-.059A3.62 3.62 0 0 1 11.94 3.71l.327.59a.502.502 0 1 0 .885-.483l-.307-.552a4.689 4.689 0 0 0-2.209-2.078 4.466 4.466 0 0 0-3.936.185A4.197 4.197 0 0 0 5.37 2.49a4.234 4.234 0 0 0-.768 1.565 4.714 4.714 0 0 0 .162 2.682.182.182 0 0 1-.085.22.173.173 0 0 1-.082.02h-.353a1.368 1.368 0 0 0-1.277.842c-.07.168-.107.348-.109.53v7.1a1.392 1.392 0 0 0 .412.974 1.352 1.352 0 0 0 .974.394h9.117c.363.001.711-.142.97-.4a1.39 1.39 0 0 0 .407-.972v-7.1a1.397 1.397 0 0 0-.414-.973 1.368 1.368 0 0 0-.972-.396Zm.37 8.469a.373.373 0 0 1-.11.26.364.364 0 0 1-.26.107H4.246a.366.366 0 0 1-.26-.107.374.374 0 0 1-.11-.261V8.349a.374.374 0 0 1 .11-.26.366.366 0 0 1 .26-.108h9.116a.366.366 0 0 1 .37.367l-.008 7.097Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.798.817h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
unlockAccount
</button>
</div>
</div>
`;
exports[`AutofillOverlayList initAutofillOverlayList the overlay with an empty list of ciphers creates the views for the no results overlay 1`] = `
<div
aria-modal="true"
class="overlay-list-container light"
role="dialog"
>
<div
class="no-items overlay-list-message"
>
noItemsToShow
</div>
<div
class="overlay-list-button-container"
>
<button
aria-label="addNewVaultItem, opensInANewWindow"
class="add-new-item-button overlay-list-button"
id="new-item-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
viewBox="0 0 16 17"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M15.222 7.914H8.963a.471.471 0 0 1-.34-.147.512.512 0 0 1-.142-.353V.99c0-.133-.05-.26-.14-.354a.471.471 0 0 0-.68 0 .51.51 0 0 0-.142.354v6.424c0 .132-.051.26-.142.353a.474.474 0 0 1-.34.147H.777a.47.47 0 0 0-.34.146.5.5 0 0 0-.14.354.522.522 0 0 0 .14.353.48.48 0 0 0 .34.147h6.26c.128 0 .25.052.34.146.09.094.142.221.142.354v6.576c0 .132.05.26.14.353a.471.471 0 0 0 .68 0 .512.512 0 0 0 .142-.353V9.414c0-.133.051-.26.142-.354a.474.474 0 0 1 .34-.146h6.26c.127 0 .25-.053.34-.147a.511.511 0 0 0 0-.707.472.472 0 0 0-.34-.146Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .49h16v16H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
newItem
</button>
</div>
</div>
`;

View File

@ -0,0 +1,389 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { createInitAutofillOverlayListMessageMock } from "../../../jest/autofill-mocks";
import { postWindowMessage } from "../../../jest/testing-utils";
import AutofillOverlayList from "./autofill-overlay-list";
describe("AutofillOverlayList", () => {
globalThis.customElements.define("autofill-overlay-list", AutofillOverlayList);
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
let autofillOverlayList: AutofillOverlayList;
beforeEach(() => {
document.body.innerHTML = `<autofill-overlay-list></autofill-overlay-list>`;
autofillOverlayList = document.querySelector("autofill-overlay-list");
autofillOverlayList["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initAutofillOverlayList", () => {
describe("the locked overlay for an unauthenticated user", () => {
beforeEach(() => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
})
);
});
it("creates the views for the locked overlay", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("allows the user to unlock the vault", () => {
const unlockButton =
autofillOverlayList["overlayListContainer"].querySelector("#unlock-button");
unlockButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "unlockVault" },
"https://localhost/"
);
});
});
describe("the overlay with an empty list of ciphers", () => {
beforeEach(() => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
})
);
});
it("creates the views for the no results overlay", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("allows the user to add a vault item", () => {
const addVaultItemButton =
autofillOverlayList["overlayListContainer"].querySelector("#new-item-button");
addVaultItemButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "addNewVaultItem" },
"https://localhost/"
);
});
});
describe("the list of ciphers for an authenticated user", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("creates the view for a list of ciphers", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("loads ciphers on scroll one page at a time", () => {
jest.useFakeTimers();
const originalListOfElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.runAllTimers();
const updatedListOfElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
expect(originalListOfElements.length).toBe(6);
expect(updatedListOfElements.length).toBe(8);
});
it("debounces the ciphers scroll handler", () => {
jest.useFakeTimers();
autofillOverlayList["cipherListScrollDebounceTimeout"] = setTimeout(jest.fn, 0);
const handleDebouncedScrollEventSpy = jest.spyOn(
autofillOverlayList as any,
"handleDebouncedScrollEvent"
);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(100);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(100);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(400);
expect(handleDebouncedScrollEventSpy).toHaveBeenCalledTimes(1);
});
describe("fill cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("allows the user to fill a cipher on click", () => {
const fillCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
fillCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillSelectedListItem", overlayCipherId: "1" },
"https://localhost/"
);
});
it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const secondFillCipherElement = fillCipherElements[1];
jest.spyOn(secondFillCipherElement as HTMLElement, "focus");
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((secondFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("directs focus to the first item in the cipher list if no cipher is present after the current one when pressing ArrowDown", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
const firstFillCipherElement = fillCipherElements[0];
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
lastFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const secondFillCipherElement = fillCipherElements[1];
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
secondFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("directs focus to the last item in the cipher list if no cipher is present before the current one when pressing ArrowUp", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
jest.spyOn(lastFillCipherElement as HTMLElement, "focus");
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((lastFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the view cipher button on ArrowRight", () => {
const cipherContainerElement =
autofillOverlayList["overlayListContainer"].querySelector(".cipher-container");
const fillCipherElement = cipherContainerElement.querySelector(".fill-cipher-button");
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
jest.spyOn(viewCipherButton as HTMLElement, "focus");
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
expect((viewCipherButton as HTMLElement).focus).toBeCalled();
});
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
const fillCipherElement =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(fillCipherElement as HTMLElement, "focus");
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
expect((fillCipherElement as HTMLElement).focus).not.toBeCalled();
});
});
describe("view cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("allows the user to view a cipher on click", () => {
const viewCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".view-cipher-button");
viewCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "viewSelectedCipher", overlayCipherId: "1" },
"https://localhost/"
);
});
it("allows the user to move keyboard focus to the current cipher element on ArrowLeft", () => {
const cipherContainerElement =
autofillOverlayList["overlayListContainer"].querySelector(".cipher-container");
const fillCipherButton = cipherContainerElement.querySelector(".fill-cipher-button");
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
jest.spyOn(fillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
expect((fillCipherButton as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard to the next cipher element on ArrowDown", () => {
const cipherContainerElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
const viewCipherButton = cipherContainerElements[0].querySelector(".view-cipher-button");
const secondFillCipherButton =
cipherContainerElements[1].querySelector(".fill-cipher-button");
jest.spyOn(secondFillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((secondFillCipherButton as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
const cipherContainerElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
const viewCipherButton = cipherContainerElements[1].querySelector(".view-cipher-button");
const firstFillCipherButton =
cipherContainerElements[0].querySelector(".fill-cipher-button");
jest.spyOn(firstFillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((firstFillCipherButton as HTMLElement).focus).toBeCalled();
});
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
const viewCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".view-cipher-button");
jest.spyOn(viewCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
expect((viewCipherButton as HTMLElement).focus).not.toBeCalled();
});
});
});
});
describe("global event listener handlers", () => {
it("does not post a `checkAutofillOverlayButtonFocused` message to the parent if the overlay is currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillOverlayListFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a `checkAutofillOverlayButtonFocused` message to the parent if the overlay is not currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
postWindowMessage({ command: "checkAutofillOverlayListFocused" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "checkAutofillOverlayButtonFocused" },
"https://localhost/"
);
});
it("updates the list of ciphers", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
const updateCiphersSpy = jest.spyOn(autofillOverlayList as any, "updateListItems");
postWindowMessage({ command: "updateOverlayListCiphers" });
expect(updateCiphersSpy).toHaveBeenCalled();
});
describe("directing user focus into the overlay list", () => {
it("focuses the unlock button element if the user is not authenticated", () => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
})
);
const unlockButton =
autofillOverlayList["overlayListContainer"].querySelector("#unlock-button");
jest.spyOn(unlockButton as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((unlockButton as HTMLElement).focus).toBeCalled();
});
it("focuses the new item button element if the cipher list is empty", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock({ ciphers: [] }));
const newItemButton =
autofillOverlayList["overlayListContainer"].querySelector("#new-item-button");
jest.spyOn(newItemButton as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((newItemButton as HTMLElement).focus).toBeCalled();
});
it("focuses the first cipher button element if the cipher list is populated", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
const firstCipherItem =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(firstCipherItem as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((firstCipherItem as HTMLElement).focus).toBeCalled();
});
});
});
describe("handleResizeObserver", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("ignores resize entries whose target is not the overlay list", () => {
const entries = [
{
target: mock<HTMLElement>(),
contentRect: { height: 300 },
},
];
autofillOverlayList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to update the overlay list height if the list container is resized", () => {
const entries = [
{
target: autofillOverlayList["overlayListContainer"],
contentRect: { height: 300 },
},
];
autofillOverlayList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "updateAutofillOverlayListHeight", styles: { height: "300px" } },
"https://localhost/"
);
});
});
});

View File

@ -0,0 +1,603 @@
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
import { EVENTS } from "../../../constants";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import {
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-list";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element";
class AutofillOverlayList extends AutofillOverlayPageElement {
private overlayListContainer: HTMLDivElement;
private resizeObserver: ResizeObserver;
private eventHandlersMemo: { [key: string]: EventListener } = {};
private ciphers: OverlayCipherData[] = [];
private ciphersList: HTMLUListElement;
private cipherListScrollIsDebounced = false;
private cipherListScrollDebounceTimeout: NodeJS.Timeout;
private currentCipherIndex = 0;
private readonly showCiphersPerPage = 6;
private readonly overlayListWindowMessageHandlers: OverlayListWindowMessageHandlers = {
initAutofillOverlayList: ({ message }) => this.initAutofillOverlayList(message),
checkAutofillOverlayListFocused: () => this.checkOverlayListFocused(),
updateOverlayListCiphers: ({ message }) => this.updateListItems(message.ciphers),
focusOverlayList: () => this.focusOverlayList(),
};
constructor() {
super();
this.setupOverlayListGlobalListeners();
}
/**
* Initializes the overlay list and updates the list items with the passed ciphers.
* If the auth status is not `Unlocked`, the locked overlay is built.
*
* @param translations - The translations to use for the overlay list.
* @param styleSheetUrl - The URL of the stylesheet to use for the overlay list.
* @param theme - The theme to use for the overlay list.
* @param authStatus - The current authentication status.
* @param ciphers - The ciphers to display in the overlay list.
*/
private async initAutofillOverlayList({
translations,
styleSheetUrl,
theme,
authStatus,
ciphers,
}: InitAutofillOverlayListMessage) {
const linkElement = this.initOverlayPage("button", styleSheetUrl, translations);
globalThis.document.documentElement.classList.add(theme);
this.overlayListContainer = globalThis.document.createElement("div");
this.overlayListContainer.classList.add("overlay-list-container", theme);
this.overlayListContainer.setAttribute("role", "dialog");
this.overlayListContainer.setAttribute("aria-modal", "true");
this.resizeObserver.observe(this.overlayListContainer);
this.shadowDom.append(linkElement, this.overlayListContainer);
if (authStatus === AuthenticationStatus.Unlocked) {
this.updateListItems(ciphers);
return;
}
this.buildLockedOverlay();
}
/**
* Builds the locked overlay, which is displayed when the user is not authenticated.
* Facilitates the ability to unlock the extension from the overlay.
*/
private buildLockedOverlay() {
const lockedOverlay = globalThis.document.createElement("div");
lockedOverlay.id = "locked-overlay-description";
lockedOverlay.classList.add("locked-overlay", "overlay-list-message");
lockedOverlay.textContent = this.getTranslation("unlockYourAccount");
const unlockButtonElement = globalThis.document.createElement("button");
unlockButtonElement.id = "unlock-button";
unlockButtonElement.tabIndex = -1;
unlockButtonElement.classList.add("unlock-button", "overlay-list-button");
unlockButtonElement.textContent = this.getTranslation("unlockAccount");
unlockButtonElement.setAttribute(
"aria-label",
`${this.getTranslation("unlockAccount")}, ${this.getTranslation("opensInANewWindow")}`
);
unlockButtonElement.prepend(buildSvgDomElement(lockIcon));
unlockButtonElement.addEventListener(EVENTS.CLICK, this.handleUnlockButtonClick);
const overlayListButtonContainer = globalThis.document.createElement("div");
overlayListButtonContainer.classList.add("overlay-list-button-container");
overlayListButtonContainer.appendChild(unlockButtonElement);
this.overlayListContainer.append(lockedOverlay, overlayListButtonContainer);
}
/**
* Handles the click event for the unlock button.
* Sends a message to the parent window to unlock the vault.
*/
private handleUnlockButtonClick = () => {
this.postMessageToParent({ command: "unlockVault" });
};
/**
* Updates the list items with the passed ciphers.
* If no ciphers are passed, the no results overlay is built.
*
* @param ciphers - The ciphers to display in the overlay list.
*/
private updateListItems(ciphers: OverlayCipherData[]) {
this.ciphers = ciphers;
this.currentCipherIndex = 0;
this.overlayListContainer.innerHTML = "";
if (!ciphers?.length) {
this.buildNoResultsOverlayList();
return;
}
this.ciphersList = globalThis.document.createElement("ul");
this.ciphersList.classList.add("overlay-actions-list");
this.ciphersList.setAttribute("role", "list");
globalThis.addEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
this.loadPageOfCiphers();
this.overlayListContainer.appendChild(this.ciphersList);
}
/**
* Overlay view that is presented when no ciphers are found for a given page.
* Facilitates the ability to add a new vault item from the overlay.
*/
private buildNoResultsOverlayList() {
const noItemsMessage = globalThis.document.createElement("div");
noItemsMessage.classList.add("no-items", "overlay-list-message");
noItemsMessage.textContent = this.getTranslation("noItemsToShow");
const newItemButton = globalThis.document.createElement("button");
newItemButton.tabIndex = -1;
newItemButton.id = "new-item-button";
newItemButton.classList.add("add-new-item-button", "overlay-list-button");
newItemButton.textContent = this.getTranslation("newItem");
newItemButton.setAttribute(
"aria-label",
`${this.getTranslation("addNewVaultItem")}, ${this.getTranslation("opensInANewWindow")}`
);
newItemButton.prepend(buildSvgDomElement(plusIcon));
newItemButton.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
const overlayListButtonContainer = globalThis.document.createElement("div");
overlayListButtonContainer.classList.add("overlay-list-button-container");
overlayListButtonContainer.appendChild(newItemButton);
this.overlayListContainer.append(noItemsMessage, overlayListButtonContainer);
}
/**
* Handles the click event for the new item button.
* Sends a message to the parent window to add a new vault item.
*/
private handeNewItemButtonClick = () => {
this.postMessageToParent({ command: "addNewVaultItem" });
};
/**
* Loads a page of ciphers into the overlay list container.
*/
private loadPageOfCiphers() {
const lastIndex = Math.min(
this.currentCipherIndex + this.showCiphersPerPage,
this.ciphers.length
);
for (let cipherIndex = this.currentCipherIndex; cipherIndex < lastIndex; cipherIndex++) {
this.ciphersList.appendChild(this.buildOverlayActionsListItem(this.ciphers[cipherIndex]));
this.currentCipherIndex++;
}
if (this.currentCipherIndex >= this.ciphers.length) {
globalThis.removeEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
}
}
/**
* Handles updating the list of ciphers when the
* user scrolls to the bottom of the list.
*/
private handleCiphersListScrollEvent = () => {
if (this.cipherListScrollIsDebounced) {
return;
}
this.cipherListScrollIsDebounced = true;
if (this.cipherListScrollDebounceTimeout) {
clearTimeout(this.cipherListScrollDebounceTimeout);
}
this.cipherListScrollDebounceTimeout = setTimeout(this.handleDebouncedScrollEvent, 300);
};
/**
* Debounced handler for updating the list of ciphers when the user scrolls to
* the bottom of the list. Triggers at most once every 300ms.
*/
private handleDebouncedScrollEvent = () => {
this.cipherListScrollIsDebounced = false;
if (globalThis.scrollY + globalThis.innerHeight >= this.ciphersList.clientHeight - 300) {
this.loadPageOfCiphers();
}
};
/**
* Builds the list item for a given cipher.
*
* @param cipher - The cipher to build the list item for.
*/
private buildOverlayActionsListItem(cipher: OverlayCipherData) {
const fillCipherElement = this.buildFillCipherElement(cipher);
const viewCipherElement = this.buildViewCipherElement(cipher);
const cipherContainerElement = globalThis.document.createElement("div");
cipherContainerElement.classList.add("cipher-container");
cipherContainerElement.append(fillCipherElement, viewCipherElement);
const overlayActionsListItem = globalThis.document.createElement("li");
overlayActionsListItem.setAttribute("role", "listitem");
overlayActionsListItem.classList.add("overlay-actions-list-item");
overlayActionsListItem.appendChild(cipherContainerElement);
return overlayActionsListItem;
}
/**
* Builds the fill cipher button for a given cipher.
* Wraps the cipher icon and details.
*
* @param cipher - The cipher to build the fill cipher button for.
*/
private buildFillCipherElement(cipher: OverlayCipherData) {
const cipherIcon = this.buildCipherIconElement(cipher);
const cipherDetailsElement = this.buildCipherDetailsElement(cipher);
const fillCipherElement = globalThis.document.createElement("button");
fillCipherElement.tabIndex = -1;
fillCipherElement.classList.add("fill-cipher-button");
fillCipherElement.setAttribute(
"aria-label",
`${this.getTranslation("fillCredentialsFor")} ${cipher.name}`
);
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("partialUsername")}, ${cipher.login.username}`
);
fillCipherElement.append(cipherIcon, cipherDetailsElement);
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
return fillCipherElement;
}
/**
* Handles the click event for the fill cipher button.
* Sends a message to the parent window to fill the selected cipher.
*
* @param cipher - The cipher to fill.
*/
private handleFillCipherClickEvent = (cipher: OverlayCipherData) => {
return this.useEventHandlersMemo(
() =>
this.postMessageToParent({
command: "fillSelectedListItem",
overlayCipherId: cipher.id,
}),
`${cipher.id}-fill-cipher-button-click-handler`
);
};
/**
* Handles the keyup event for the fill cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp. Also
* facilitates moving keyboard focus to the view cipher button on ArrowRight.
*
* @param event - The keyup event.
*/
private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
const currentListItem = event.target.closest(".overlay-actions-list-item") as HTMLElement;
if (event.code === "ArrowDown") {
this.focusNextListItem(currentListItem);
return;
}
if (event.code === "ArrowUp") {
this.focusPreviousListItem(currentListItem);
return;
}
this.focusViewCipherButton(currentListItem, event.target as HTMLElement);
};
/**
* Builds the button that facilitates viewing a cipher in the vault.
*
* @param cipher - The cipher to view.
*/
private buildViewCipherElement(cipher: OverlayCipherData) {
const viewCipherElement = globalThis.document.createElement("button");
viewCipherElement.tabIndex = -1;
viewCipherElement.classList.add("view-cipher-button");
viewCipherElement.setAttribute(
"aria-label",
`${this.getTranslation("view")} ${cipher.name}, ${this.getTranslation("opensInANewWindow")}`
);
viewCipherElement.append(buildSvgDomElement(viewCipherIcon));
viewCipherElement.addEventListener(EVENTS.CLICK, this.handleViewCipherClickEvent(cipher));
viewCipherElement.addEventListener(EVENTS.KEYUP, this.handleViewCipherKeyUpEvent);
return viewCipherElement;
}
/**
* Handles the click event for the view cipher button. Sends a
* message to the parent window to view the selected cipher.
*
* @param cipher - The cipher to view.
*/
private handleViewCipherClickEvent = (cipher: OverlayCipherData) => {
return this.useEventHandlersMemo(
() => this.postMessageToParent({ command: "viewSelectedCipher", overlayCipherId: cipher.id }),
`${cipher.id}-view-cipher-button-click-handler`
);
};
/**
* Handles the keyup event for the view cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp.
* Also facilitates moving keyboard focus to the current fill
* cipher button on ArrowLeft.
*
* @param event - The keyup event.
*/
private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
const currentListItem = event.target.closest(".overlay-actions-list-item") as HTMLElement;
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
cipherContainer?.classList.remove("remove-outline");
if (event.code === "ArrowDown") {
this.focusNextListItem(currentListItem);
return;
}
if (event.code === "ArrowUp") {
this.focusPreviousListItem(currentListItem);
return;
}
const previousSibling = event.target.previousElementSibling as HTMLElement;
previousSibling?.focus();
};
/**
* Builds the icon for a given cipher. Prioritizes the favicon from a given cipher url
* and the default icon element within the extension. If neither are available, the
* globe icon is used.
*
* @param cipher - The cipher to build the icon for.
*/
private buildCipherIconElement(cipher: OverlayCipherData) {
const cipherIcon = globalThis.document.createElement("span");
cipherIcon.classList.add("cipher-icon");
cipherIcon.setAttribute("aria-hidden", "true");
if (cipher.icon?.image) {
try {
const url = new URL(cipher.icon.image);
cipherIcon.style.backgroundImage = `url(${url.href})`;
return cipherIcon;
} catch {
// Silently default to the globe icon element if the image URL is invalid
}
}
if (cipher.icon?.icon) {
cipherIcon.classList.add("cipher-icon", "bwi", cipher.icon.icon);
return cipherIcon;
}
cipherIcon.append(buildSvgDomElement(globeIcon));
return cipherIcon;
}
/**
* Builds the details for a given cipher. Includes the cipher name and username login.
*
* @param cipher - The cipher to build the details for.
*/
private buildCipherDetailsElement(cipher: OverlayCipherData) {
const cipherNameElement = this.buildCipherNameElement(cipher);
const cipherUserLoginElement = this.buildCipherUserLoginElement(cipher);
const cipherDetailsElement = globalThis.document.createElement("span");
cipherDetailsElement.classList.add("cipher-details");
if (cipherNameElement) {
cipherDetailsElement.appendChild(cipherNameElement);
}
if (cipherUserLoginElement) {
cipherDetailsElement.appendChild(cipherUserLoginElement);
}
return cipherDetailsElement;
}
/**
* Builds the name element for a given cipher.
*
* @param cipher - The cipher to build the name element for.
*/
private buildCipherNameElement(cipher: OverlayCipherData): HTMLSpanElement | null {
if (!cipher.name) {
return null;
}
const cipherNameElement = globalThis.document.createElement("span");
cipherNameElement.classList.add("cipher-name");
cipherNameElement.textContent = cipher.name;
cipherNameElement.setAttribute("title", cipher.name);
return cipherNameElement;
}
/**
* Builds the username login element for a given cipher.
*
* @param cipher - The cipher to build the username login element for.
*/
private buildCipherUserLoginElement(cipher: OverlayCipherData): HTMLSpanElement | null {
if (!cipher.login?.username) {
return null;
}
const cipherUserLoginElement = globalThis.document.createElement("span");
cipherUserLoginElement.classList.add("cipher-user-login");
cipherUserLoginElement.textContent = cipher.login.username;
cipherUserLoginElement.setAttribute("title", cipher.login.username);
return cipherUserLoginElement;
}
/**
* Validates whether the overlay list iframe is currently focused.
* If not focused, will check if the button element is focused.
*/
private checkOverlayListFocused() {
if (globalThis.document.hasFocus()) {
return;
}
this.postMessageToParent({ command: "checkAutofillOverlayButtonFocused" });
}
/**
* Focuses the overlay list iframe. The element that receives focus is
* determined by the presence of the unlock button, new item button, or
* the first cipher button.
*/
private focusOverlayList() {
const unlockButtonElement = this.overlayListContainer.querySelector(
"#unlock-button"
) as HTMLElement;
if (unlockButtonElement) {
unlockButtonElement.focus();
return;
}
const newItemButtonElement = this.overlayListContainer.querySelector(
"#new-item-button"
) as HTMLElement;
if (newItemButtonElement) {
newItemButtonElement.focus();
return;
}
const firstCipherElement = this.overlayListContainer.querySelector(
".fill-cipher-button"
) as HTMLElement;
firstCipherElement?.focus();
}
/**
* Sets up the global listeners for the overlay list iframe.
*/
private setupOverlayListGlobalListeners() {
this.setupGlobalListeners(this.overlayListWindowMessageHandlers);
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
}
/**
* Handles the resize observer event. Facilitates updating the height of the
* overlay list iframe when the height of the list changes.
*
* @param entries - The resize observer entries.
*/
private handleResizeObserver = (entries: ResizeObserverEntry[]) => {
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
const entry = entries[entryIndex];
if (entry.target !== this.overlayListContainer) {
continue;
}
const { height } = entry.contentRect;
this.postMessageToParent({
command: "updateAutofillOverlayListHeight",
styles: { height: `${height}px` },
});
break;
}
};
/**
* Establishes a memoized event handler for a given event.
*
* @param eventHandler - The event handler to memoize.
* @param memoIndex - The memo index to use for the event handler.
*/
private useEventHandlersMemo = (eventHandler: EventListener, memoIndex: string) => {
return this.eventHandlersMemo[memoIndex] || (this.eventHandlersMemo[memoIndex] = eventHandler);
};
/**
* Focuses the next list item in the overlay list. If the current list item is the last
* item in the list, the first item is focused.
*
* @param currentListItem - The current list item.
*/
private focusNextListItem(currentListItem: HTMLElement) {
const nextListItem = currentListItem.nextSibling as HTMLElement;
const nextSibling = nextListItem?.querySelector(".fill-cipher-button") as HTMLElement;
if (nextSibling) {
nextSibling.focus();
return;
}
const firstListItem = currentListItem.parentElement?.firstChild as HTMLElement;
const firstSibling = firstListItem?.querySelector(".fill-cipher-button") as HTMLElement;
firstSibling?.focus();
}
/**
* Focuses the previous list item in the overlay list. If the current list item is the first
* item in the list, the last item is focused.
*
* @param currentListItem - The current list item.
*/
private focusPreviousListItem(currentListItem: HTMLElement) {
const previousListItem = currentListItem.previousSibling as HTMLElement;
const previousSibling = previousListItem?.querySelector(".fill-cipher-button") as HTMLElement;
if (previousSibling) {
previousSibling.focus();
return;
}
const lastListItem = currentListItem.parentElement?.lastChild as HTMLElement;
const lastSibling = lastListItem?.querySelector(".fill-cipher-button") as HTMLElement;
lastSibling?.focus();
}
/**
* Focuses the view cipher button relative to the current fill cipher button.
*
* @param currentListItem - The current list item.
* @param currentButtonElement - The current button element.
*/
private focusViewCipherButton(currentListItem: HTMLElement, currentButtonElement: HTMLElement) {
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
cipherContainer.classList.add("remove-outline");
const nextSibling = currentButtonElement.nextElementSibling as HTMLElement;
nextSibling?.focus();
}
}
export default AutofillOverlayList;

View File

@ -0,0 +1,9 @@
import { AutofillOverlayElement } from "../../../utils/autofill-overlay.enum";
import AutofillOverlayList from "./autofill-overlay-list";
require("./list.scss");
(function () {
globalThis.customElements.define(AutofillOverlayElement.List, AutofillOverlayList);
})();

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bitwarden vault</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="normal" />
</head>
<body>
<autofill-overlay-list></autofill-overlay-list>
</body>
</html>

View File

@ -0,0 +1,293 @@
@import "../../../../../../../libs/angular/src/scss/webfonts.css";
@import "../../../../../../../libs/angular/src/scss/bwicons/styles/style";
@import "../../../shared/styles/variables";
@import "../../../../../../../libs/angular/src/scss/icons";
* {
box-sizing: border-box;
}
html {
font-size: 10px;
}
body {
width: 100%;
padding: 0;
margin: 0;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
.overlay-list-message {
font-family: $font-family-sans-serif;
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
width: 100%;
padding: 0.8rem;
@include themify($themes) {
color: themed("textColor");
}
&.no-items {
font-size: 1.6rem;
}
}
.overlay-list-button-container {
width: 100%;
padding: 0.2rem;
background: transparent;
transition: background-color 0.2s ease-in-out;
border-top-width: 0.1rem;
border-top-style: solid;
@include themify($themes) {
border-top-color: themed("borderColor");
}
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
}
.overlay-list-button {
display: flex;
align-content: center;
justify-content: flex-start;
width: 100%;
font-family: $font-family-sans-serif;
font-size: 1.6rem;
font-weight: 700;
text-align: left;
background: transparent;
border: none;
padding: 0.7rem;
margin: 0;
cursor: pointer;
border-radius: 0.4rem;
@include themify($themes) {
color: themed("primaryColor");
}
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
position: relative;
margin-left: 0.4rem;
margin-right: 0.8rem;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.unlock-button {
svg {
top: 0.2rem;
width: 1.6rem;
height: 1.7rem;
}
}
.add-new-item-button {
svg {
top: 0.2rem;
width: 1.7rem;
height: 1.7rem;
}
}
.overlay-actions-list {
padding: 0;
margin: 0;
}
.overlay-actions-list-item {
transition: background-color 0.2s ease-in-out;
list-style: none;
padding: 0.2rem;
&:not(:last-child) {
border-bottom-width: 0.1rem;
border-bottom-style: solid;
@include themify($themes) {
border-bottom-color: themed("borderColor");
}
}
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
.cipher-container {
display: flex;
align-content: flex-start;
align-items: center;
justify-content: flex-start;
padding: 0.7rem 0.3rem 0.7rem 0.7rem;
border-radius: 0.4rem;
&:focus-within:not(.remove-outline) {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
}
.fill-cipher-button,
.view-cipher-button {
padding: 0;
margin: 0;
line-height: 0;
background-color: transparent;
border: none;
cursor: pointer;
}
.fill-cipher-button {
display: flex;
align-items: center;
align-content: center;
justify-content: flex-start;
width: calc(100% - 4rem);
outline: none;
}
.view-cipher-button {
flex-shrink: 0;
width: 4rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.4rem;
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.cipher-icon {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3.2rem;
height: 3.2rem;
margin: 0 1rem 0 0;
line-height: 0;
background-size: 2.6rem;
background-position: center;
background-repeat: no-repeat;
@include themify($themes) {
color: themed("mutedTextColor");
}
svg {
width: 100%;
height: auto;
flex-shrink: 0;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
&.bwi {
font-size: 2.6rem;
&:not(.cipher-icon) {
@include themify($themes) {
color: themed("primaryColor");
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
}
}
.cipher-details {
display: block;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
text-align: left;
}
.cipher-name,
.cipher-user-login {
display: block;
width: 100%;
line-height: 1.5;
font-family: $font-family-sans-serif;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
.cipher-name {
font-size: 1.6rem;
@include themify($themes) {
color: themed("textColor");
}
}
.cipher-user-login {
font-size: 1.4rem;
@include themify($themes) {
color: themed("mutedTextColor");
}
}
}

View File

@ -0,0 +1,219 @@
import { mock } from "jest-mock-extended";
import { OverlayButtonWindowMessageHandlers } from "../../abstractions/autofill-overlay-button";
import AutofillOverlayPageElement from "./autofill-overlay-page-element";
describe("AutofillOverlayPageElement", () => {
globalThis.customElements.define("autofill-overlay-page-element", AutofillOverlayPageElement);
let autofillOverlayPageElement: AutofillOverlayPageElement;
const translations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
};
beforeEach(() => {
jest.spyOn(globalThis.parent, "postMessage");
jest.spyOn(globalThis, "addEventListener");
jest.spyOn(globalThis.document, "addEventListener");
document.body.innerHTML = "<autofill-overlay-page-element></autofill-overlay-page-element>";
autofillOverlayPageElement = document.querySelector("autofill-overlay-page-element");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initOverlayPage", () => {
beforeEach(() => {
jest.spyOn(globalThis.document.documentElement, "setAttribute");
jest.spyOn(globalThis.document, "createElement");
});
it("initializes the button overlay page", () => {
const linkElement = autofillOverlayPageElement["initOverlayPage"](
"button",
"https://jest-testing-website.com",
translations
);
expect(globalThis.document.documentElement.setAttribute).toHaveBeenCalledWith(
"lang",
translations.locale
);
expect(globalThis.document.head.title).toEqual(translations.buttonPageTitle);
expect(globalThis.document.createElement).toHaveBeenCalledWith("link");
expect(linkElement.getAttribute("rel")).toEqual("stylesheet");
expect(linkElement.getAttribute("href")).toEqual("https://jest-testing-website.com");
});
});
describe("postMessageToParent", () => {
it("skips posting a message to the parent if the message origin in not set", () => {
autofillOverlayPageElement["postMessageToParent"]({ command: "test" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to the parent", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["postMessageToParent"]({ command: "test" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "test" },
"https://jest-testing-website.com"
);
});
});
describe("getTranslation", () => {
it("returns an empty value if the translation doesn't exist in the translations object", () => {
autofillOverlayPageElement["translations"] = translations;
expect(autofillOverlayPageElement["getTranslation"]("test")).toEqual("");
});
});
describe("global event listeners", () => {
it("sets up global event listeners", () => {
const handleWindowMessageSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleWindowMessage"
);
const handleWindowBlurEventSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleWindowBlurEvent"
);
const handleDocumentKeyDownEventSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleDocumentKeyDownEvent"
);
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>()
);
expect(globalThis.addEventListener).toHaveBeenCalledWith("message", handleWindowMessageSpy);
expect(globalThis.addEventListener).toHaveBeenCalledWith("blur", handleWindowBlurEventSpy);
expect(globalThis.document.addEventListener).toHaveBeenCalledWith(
"keydown",
handleDocumentKeyDownEventSpy
);
});
it("sets the message origin when handling the first passed window message", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
})
);
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "initAutofillOverlayButton" },
origin: "https://jest-testing-website.com",
})
);
expect(autofillOverlayPageElement["messageOrigin"]).toEqual(
"https://jest-testing-website.com"
);
});
it("handles window messages that are part of the passed windowMessageHandlers object", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
})
);
const data = { command: "initAutofillOverlayButton" };
globalThis.dispatchEvent(new MessageEvent("message", { data }));
expect(initAutofillOverlayButtonSpy).toHaveBeenCalledWith({ message: data });
});
it("skips attempting to handle window messages that are not part of the passed windowMessageHandlers object", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
})
);
globalThis.dispatchEvent(new MessageEvent("message", { data: { command: "test" } }));
expect(initAutofillOverlayButtonSpy).not.toHaveBeenCalled();
});
it("posts a message to the parent when the window is blurred", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>()
);
globalThis.dispatchEvent(new Event("blur"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "overlayPageBlurred" },
"https://jest-testing-website.com"
);
});
it("skips redirecting keyboard focus when a KeyDown event triggers and the key is not a `Tab` or `Escape` key", () => {
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>()
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "test" }));
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("redirects the overlay focus out to the previous element on KeyDown of the `Tab+Shift` keys", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>()
);
globalThis.document.dispatchEvent(
new KeyboardEvent("keydown", { code: "Tab", shiftKey: true })
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "previous" },
"https://jest-testing-website.com"
);
});
it("redirects the overlay focus out to the next element on KeyDown of the `Tab` key", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>()
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Tab" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "next" },
"https://jest-testing-website.com"
);
});
it("redirects the overlay focus out to the current element on KeyDown of the `Escape` key", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>()
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "current" },
"https://jest-testing-website.com"
);
});
});
});

View File

@ -0,0 +1,150 @@
import { EVENTS } from "../../../constants";
import { RedirectFocusDirection } from "../../../utils/autofill-overlay.enum";
import {
AutofillOverlayPageElementWindowMessage,
WindowMessageHandlers,
} from "../../abstractions/autofill-overlay-page-element";
class AutofillOverlayPageElement extends HTMLElement {
protected shadowDom: ShadowRoot;
protected messageOrigin: string;
protected translations: Record<string, string>;
protected windowMessageHandlers: WindowMessageHandlers;
constructor() {
super();
this.shadowDom = this.attachShadow({ mode: "closed" });
}
/**
* Initializes the overlay page element. Facilitates ensuring that the page
* is set up with the expected styles and translations.
*
* @param elementName - The name of the element, e.g. "button" or "list"
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
* @param translations - The translations to apply to the page
*/
protected initOverlayPage(
elementName: "button" | "list",
styleSheetUrl: string,
translations: Record<string, string>
): HTMLLinkElement {
this.translations = translations;
globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale"));
globalThis.document.head.title = this.getTranslation(`${elementName}PageTitle`);
this.shadowDom.innerHTML = "";
const linkElement = globalThis.document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("href", styleSheetUrl);
return linkElement;
}
/**
* Posts a window message to the parent window.
*
* @param message - The message to post
*/
protected postMessageToParent(message: AutofillOverlayPageElementWindowMessage) {
if (!this.messageOrigin) {
return;
}
globalThis.parent.postMessage(message, this.messageOrigin);
}
/**
* Gets a translation from the translations object.
*
* @param key
* @protected
*/
protected getTranslation(key: string): string {
return this.translations[key] || "";
}
/**
* Sets up global listeners for the window message, window blur, and
* document keydown events.
*
* @param windowMessageHandlers - The window message handlers to use
*/
protected setupGlobalListeners(windowMessageHandlers: WindowMessageHandlers) {
this.windowMessageHandlers = windowMessageHandlers;
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
globalThis.addEventListener(EVENTS.BLUR, this.handleWindowBlurEvent);
globalThis.document.addEventListener(EVENTS.KEYDOWN, this.handleDocumentKeyDownEvent);
}
/**
* Handles window messages from the parent window.
*
* @param event - The window message event
*/
private handleWindowMessage = (event: MessageEvent) => {
if (!this.windowMessageHandlers) {
return;
}
if (!this.messageOrigin) {
this.messageOrigin = event.origin;
}
const message = event?.data;
const handler = this.windowMessageHandlers[message?.command];
if (!handler) {
return;
}
handler({ message });
};
/**
* Handles the window blur event.
*/
private handleWindowBlurEvent = () => {
this.postMessageToParent({ command: "overlayPageBlurred" });
};
/**
* Handles the document keydown event. Facilitates redirecting the
* user focus in the right direction out of the overlay. Also facilitates
* closing the overlay when the user presses the Escape key.
*
* @param event - The document keydown event
*/
private handleDocumentKeyDownEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["Tab", "Escape"]);
if (!listenedForKeys.has(event.code)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.code === "Tab") {
this.redirectOverlayFocusOutMessage(
event.shiftKey ? RedirectFocusDirection.Previous : RedirectFocusDirection.Next
);
return;
}
this.redirectOverlayFocusOutMessage(RedirectFocusDirection.Current);
};
/**
* Redirects the overlay focus out to the previous element on KeyDown of the `Tab+Shift` keys.
* Redirects the overlay focus out to the next element on KeyDown of the `Tab` key.
* Redirects the overlay focus out to the current element on KeyDown of the `Escape` key.
*
* @param direction - The direction to redirect the focus out
*/
private redirectOverlayFocusOutMessage(direction: string) {
this.postMessageToParent({ command: "redirectOverlayFocusOut", direction });
}
}
export default AutofillOverlayPageElement;

View File

@ -0,0 +1,32 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import AutofillField from "../../models/autofill-field";
import { ElementWithOpId, FormFieldElement } from "../../types";
type OpenAutofillOverlayOptions = {
isFocusingFieldElement?: boolean;
isOpeningFullOverlay?: boolean;
authStatus?: AuthenticationStatus;
};
interface AutofillOverlayContentService {
isFieldCurrentlyFocused: boolean;
isCurrentlyFilling: boolean;
isOverlayCiphersPopulated: boolean;
pageDetailsUpdateRequired: boolean;
init(): void;
setupAutofillOverlayListenerOnField(
autofillFieldElement: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField
): Promise<void>;
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
removeAutofillOverlay(): void;
removeAutofillOverlayButton(): void;
removeAutofillOverlayList(): void;
addNewVaultItem(): void;
redirectOverlayFocusOut(direction: "previous" | "next"): void;
focusMostRecentOverlayField(): void;
blurMostRecentOverlayField(): void;
}
export { OpenAutofillOverlayOptions, AutofillOverlayContentService };

View File

@ -47,7 +47,8 @@ export interface GenerateFillScriptOptions {
export abstract class AutofillService {
injectAutofillScripts: (
sender: chrome.runtime.MessageSender,
autofillV2?: boolean
autofillV2?: boolean,
autofillOverlay?: boolean
) => Promise<void>;
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
doAutoFill: (options: AutoFillOptions) => Promise<string | null>;
@ -61,4 +62,5 @@ export abstract class AutofillService {
fromCommand: boolean,
cipherType?: CipherType
) => Promise<string | null>;
isPasswordRepromptRequired: (cipher: CipherView, tab: chrome.tabs.Tab) => Promise<boolean>;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@ import { triggerTestFailure } from "../jest/testing-utils";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import { AutofillOverlayVisibility } from "../utils/autofill-overlay.enum";
import {
AutoFillOptions,
@ -73,7 +74,8 @@ describe("AutofillService", () => {
describe("injectAutofillScripts", () => {
const autofillV1Script = "autofill.js";
const autofillV2Script = "autofill-init.js";
const autofillV2BootstrapScript = "bootstrap-autofill.js";
const autofillOverlayBootstrapScript = "bootstrap-autofill-overlay.js";
const defaultAutofillScripts = ["autofiller.js", "notificationBar.js", "contextMenuHandler.js"];
const defaultExecuteScriptOptions = { runAt: "document_start" };
let tabMock: chrome.tabs.Tab;
@ -96,17 +98,60 @@ describe("AutofillService", () => {
});
});
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2Script}`,
file: `content/${autofillV2BootstrapScript}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
});
it("will inject the autofill-init class if the enableAutofillV2 flag is set", () => {
autofillService.injectAutofillScripts(sender, true);
it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => {
await autofillService.injectAutofillScripts(sender, true);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2Script}`,
file: `content/${autofillV2BootstrapScript}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV1Script}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
});
it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => {
jest
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
await autofillService.injectAutofillScripts(sender, true, true);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillOverlayBootstrapScript}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV1Script}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2BootstrapScript}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
});
it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => {
jest
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.Off);
await autofillService.injectAutofillScripts(sender, true, true);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2BootstrapScript}`,
...defaultExecuteScriptOptions,
});
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
@ -843,12 +888,116 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(tab);
jest.spyOn(autofillService, "doAutoFillOnTab").mockResolvedValueOnce(totp);
const result = await autofillService.doAutoFillActiveTab(pageDetails, fromCommand);
const result = await autofillService.doAutoFillActiveTab(
pageDetails,
fromCommand,
CipherType.Login
);
expect(autofillService["getActiveTab"]).toHaveBeenCalled();
expect(autofillService.doAutoFillOnTab).toHaveBeenCalledWith(pageDetails, tab, fromCommand);
expect(result).toBe(totp);
});
it("auto-fills card cipher types", async () => {
const cardFormPageDetails = [
{
frameId: 1,
tab: createChromeTabMock(),
details: createAutofillPageDetailsMock({
fields: [
createAutofillFieldMock({
opid: "number-field",
form: "validFormId",
elementNumber: 1,
}),
createAutofillFieldMock({
opid: "ccv-field",
form: "validFormId",
elementNumber: 2,
}),
],
}),
},
];
const cardCipher = mock<CipherView>({
type: CipherType.Card,
reprompt: CipherRepromptType.None,
});
jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(tab);
jest.spyOn(autofillService, "doAutoFill").mockImplementation();
jest
.spyOn(autofillService["cipherService"], "getAllDecryptedForUrl")
.mockResolvedValueOnce([cardCipher]);
await autofillService.doAutoFillActiveTab(cardFormPageDetails, false, CipherType.Card);
expect(autofillService["cipherService"].getAllDecryptedForUrl).toHaveBeenCalled();
expect(autofillService.doAutoFill).toHaveBeenCalledWith({
tab: tab,
cipher: cardCipher,
pageDetails: cardFormPageDetails,
skipLastUsed: true,
skipUsernameOnlyFill: true,
onlyEmptyFields: true,
onlyVisibleFields: true,
fillNewPassword: false,
allowUntrustedIframe: false,
allowTotpAutofill: false,
});
});
it("auto-fills identity cipher types", async () => {
const identityFormPageDetails = [
{
frameId: 1,
tab: createChromeTabMock(),
details: createAutofillPageDetailsMock({
fields: [
createAutofillFieldMock({
opid: "name-field",
form: "validFormId",
elementNumber: 1,
}),
createAutofillFieldMock({
opid: "address-field",
form: "validFormId",
elementNumber: 2,
}),
],
}),
},
];
const identityCipher = mock<CipherView>({
type: CipherType.Identity,
reprompt: CipherRepromptType.None,
});
jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(tab);
jest.spyOn(autofillService, "doAutoFill").mockImplementation();
jest
.spyOn(autofillService["cipherService"], "getAllDecryptedForUrl")
.mockResolvedValueOnce([identityCipher]);
await autofillService.doAutoFillActiveTab(
identityFormPageDetails,
false,
CipherType.Identity
);
expect(autofillService["cipherService"].getAllDecryptedForUrl).toHaveBeenCalled();
expect(autofillService.doAutoFill).toHaveBeenCalledWith({
tab: tab,
cipher: identityCipher,
pageDetails: identityFormPageDetails,
skipLastUsed: true,
skipUsernameOnlyFill: true,
onlyEmptyFields: true,
onlyVisibleFields: true,
fillNewPassword: false,
allowUntrustedIframe: false,
allowTotpAutofill: false,
});
});
});
describe("getActiveTab", () => {
@ -4276,5 +4425,15 @@ describe("AutofillService", () => {
expect(result).toBe(true);
});
it("resets the currentlyOpeningPasswordRepromptPopout value to false after the debounce has occurred", () => {
jest.useFakeTimers();
const result = autofillService["isDebouncingPasswordRepromptPopout"]();
jest.advanceTimersByTime(100);
expect(result).toBe(false);
expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false);
});
});
});

View File

@ -16,6 +16,7 @@ import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vau
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import { AutofillOverlayVisibility } from "../utils/autofill-overlay.enum";
import {
AutoFillOptions,
@ -52,10 +53,25 @@ export default class AutofillService implements AutofillServiceInterface {
* is enabled.
* @param {chrome.runtime.MessageSender} sender
* @param {boolean} autofillV2
* @param {boolean} autofillOverlay
* @returns {Promise<void>}
*/
async injectAutofillScripts(sender: chrome.runtime.MessageSender, autofillV2 = false) {
const mainAutofillScript = autofillV2 ? `autofill-init.js` : "autofill.js";
async injectAutofillScripts(
sender: chrome.runtime.MessageSender,
autofillV2 = false,
autofillOverlay = false
) {
let mainAutofillScript = "autofill.js";
const isUsingAutofillOverlay =
autofillOverlay &&
(await this.settingsService.getAutoFillOverlayVisibility()) !== AutofillOverlayVisibility.Off;
if (autofillV2) {
mainAutofillScript = isUsingAutofillOverlay
? "bootstrap-autofill-overlay.js"
: "bootstrap-autofill.js";
}
const injectedScripts = [
mainAutofillScript,
@ -276,20 +292,13 @@ export default class AutofillService implements AutofillServiceInterface {
}
if (
cipher.reprompt === CipherRepromptType.Password &&
// If the master password has is not available, reprompt will error
(await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) &&
(await this.isPasswordRepromptRequired(cipher, tab)) &&
!this.isDebouncingPasswordRepromptPopout()
) {
if (fromCommand) {
this.cipherService.updateLastUsedIndexForUrl(tab.url);
}
await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: "autofill",
});
return null;
}
@ -314,6 +323,21 @@ export default class AutofillService implements AutofillServiceInterface {
return totpCode;
}
async isPasswordRepromptRequired(cipher: CipherView, tab: chrome.tabs.Tab): Promise<boolean> {
const userHasMasterPasswordAndKeyHash =
await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
if (cipher.reprompt === CipherRepromptType.Password && userHasMasterPasswordAndKeyHash) {
await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: "autofill",
});
return true;
}
return false;
}
/**
* Autofill the active tab with the next cipher from the cache
* @param {PageDetail[]} pageDetails The data scraped from the page

View File

@ -9,6 +9,7 @@ import {
FormElementWithAttribute,
} from "../types";
import AutofillOverlayContentService from "./autofill-overlay-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
@ -23,11 +24,15 @@ const mockLoginForm = `
describe("CollectAutofillContentService", () => {
const domElementVisibilityService = new DomElementVisibilityService();
const autofillOverlayContentService = new AutofillOverlayContentService();
let collectAutofillContentService: CollectAutofillContentService;
beforeEach(() => {
document.body.innerHTML = mockLoginForm;
collectAutofillContentService = new CollectAutofillContentService(domElementVisibilityService);
collectAutofillContentService = new CollectAutofillContentService(
domElementVisibilityService,
autofillOverlayContentService
);
});
afterEach(() => {

View File

@ -8,16 +8,18 @@ import {
FormElementWithAttribute,
} from "../types";
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
import {
UpdateAutofillDataAttributeParams,
AutofillFieldElements,
AutofillFormElements,
CollectAutofillContentService as CollectAutofillContentServiceInterface,
} from "./abstractions/collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
class CollectAutofillContentService implements CollectAutofillContentServiceInterface {
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly autofillOverlayContentService: AutofillOverlayContentService;
private noFieldsFound = false;
private domRecentlyMutated = true;
private autofillFormElements: AutofillFormElements = new Map();
@ -27,8 +29,12 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
private updateAutofillElementsAfterMutationTimeout: NodeJS.Timeout;
private readonly updateAfterMutationTimeoutDelay = 1000;
constructor(domElementVisibilityService: DomElementVisibilityService) {
constructor(
domElementVisibilityService: DomElementVisibilityService,
autofillOverlayContentService?: AutofillOverlayContentService
) {
this.domElementVisibilityService = domElementVisibilityService;
this.autofillOverlayContentService = autofillOverlayContentService;
}
/**
@ -320,6 +326,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
if (element instanceof HTMLSpanElement) {
this.autofillFieldElements.set(element, autofillFieldBase);
this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(
element,
autofillFieldBase
);
return autofillFieldBase;
}
@ -357,6 +367,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
};
this.autofillFieldElements.set(element, autofillField);
this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(element, autofillField);
return autofillField;
};
@ -445,7 +456,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
labelElementsSet.add(currentElement);
}
currentElement = currentElement.parentElement.closest("label");
currentElement = currentElement.parentElement?.closest("label");
}
if (
@ -926,6 +937,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
this.isAutofillElementNodeMutated(mutation.addedNodes))
) {
this.domRecentlyMutated = true;
if (this.autofillOverlayContentService) {
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
}
this.noFieldsFound = false;
continue;
}
@ -949,6 +963,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
this.currentLocationHref = globalThis.location.href;
this.domRecentlyMutated = true;
if (this.autofillOverlayContentService) {
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
}
this.noFieldsFound = false;
this.autofillFormElements.clear();
@ -993,13 +1010,23 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
}
}
if (isRemovingNodes) {
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
const node = mutatedElements[elementIndex];
if (isRemovingNodes) {
this.deleteCachedAutofillElement(
mutatedElements[elementIndex] as
| ElementWithOpId<HTMLFormElement>
| ElementWithOpId<FormFieldElement>
node as ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>
);
continue;
}
if (
this.autofillOverlayContentService &&
this.isNodeFormFieldElement(node) &&
!this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
) {
// We are setting this item to a -1 index because we do not know its position in the DOM.
// This value should be updated with the next call to collect page details.
this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1);
}
}

View File

@ -2,6 +2,7 @@ import { EVENTS } from "../constants";
import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script";
import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types";
import AutofillOverlayContentService from "./autofill-overlay-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
import InsertAutofillContentService from "./insert-autofill-content.service";
@ -64,8 +65,10 @@ function setMockWindowLocation({
describe("InsertAutofillContentService", () => {
const domElementVisibilityService = new DomElementVisibilityService();
const autofillOverlayContentService = new AutofillOverlayContentService();
const collectAutofillContentService = new CollectAutofillContentService(
domElementVisibilityService
domElementVisibilityService,
autofillOverlayContentService
);
let insertAutofillContentService: InsertAutofillContentService;
let fillScript: AutofillScript;
@ -103,14 +106,14 @@ describe("InsertAutofillContentService", () => {
});
describe("fillForm", () => {
it("returns early if the passed fill script does not have a script property", () => {
it("returns early if the passed fill script does not have a script property", async () => {
fillScript.script = [];
jest.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe");
jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill");
jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill");
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
insertAutofillContentService.fillForm(fillScript);
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).not.toHaveBeenCalled();
expect(
@ -122,7 +125,7 @@ describe("InsertAutofillContentService", () => {
expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled();
});
it("returns early if the script is filling within a sand boxed iframe", () => {
it("returns early if the script is filling within a sand boxed iframe", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(true);
@ -130,7 +133,7 @@ describe("InsertAutofillContentService", () => {
jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill");
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
insertAutofillContentService.fillForm(fillScript);
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(
@ -142,7 +145,7 @@ describe("InsertAutofillContentService", () => {
expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled();
});
it("returns early if the autofill is occurring on an insecure url and the user cancels the autofill", () => {
it("returns early if the autofill is occurring on an insecure url and the user cancels the autofill", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(false);
@ -152,7 +155,7 @@ describe("InsertAutofillContentService", () => {
jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill");
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
insertAutofillContentService.fillForm(fillScript);
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled();
@ -162,7 +165,7 @@ describe("InsertAutofillContentService", () => {
expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled();
});
it("returns early if the iframe is untrusted and the user cancelled the autofill", () => {
it("returns early if the iframe is untrusted and the user cancelled the autofill", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(false);
@ -174,7 +177,7 @@ describe("InsertAutofillContentService", () => {
.mockReturnValue(true);
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
insertAutofillContentService.fillForm(fillScript);
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled();
@ -184,7 +187,7 @@ describe("InsertAutofillContentService", () => {
expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled();
});
it("runs the fill script action for all scripts found within the fill script", () => {
it("runs the fill script action for all scripts found within the fill script", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(false);
@ -196,7 +199,7 @@ describe("InsertAutofillContentService", () => {
.mockReturnValue(false);
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
insertAutofillContentService.fillForm(fillScript);
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled();
@ -399,14 +402,14 @@ describe("InsertAutofillContentService", () => {
jest.useFakeTimers();
});
it("returns early if no opid is provided", () => {
it("returns early if no opid is provided", async () => {
const action = "fill_by_opid";
const opid = "";
const value = "value";
const scriptAction: FillScript = [action, opid, value];
jest.spyOn(insertAutofillContentService["autofillInsertActions"], action);
insertAutofillContentService["runFillScriptAction"](scriptAction, 0);
await insertAutofillContentService["runFillScriptAction"](scriptAction, 0);
jest.advanceTimersByTime(20);
expect(insertAutofillContentService["autofillInsertActions"][action]).not.toHaveBeenCalled();

View File

@ -31,9 +31,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* Handles autofill of the forms on the current page based on the
* data within the passed fill script object.
* @param {AutofillScript} fillScript
* @returns {Promise<void>}
* @public
*/
fillForm(fillScript: AutofillScript) {
async fillForm(fillScript: AutofillScript) {
if (
!fillScript.script?.length ||
this.fillingWithinSandboxedIframe() ||
@ -43,7 +44,8 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
return;
}
fillScript.script.forEach(this.runFillScriptAction);
const fillActionPromises = fillScript.script.map(this.runFillScriptAction);
await Promise.all(fillActionPromises);
}
/**
@ -128,20 +130,27 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
/**
* Runs the autofill action based on the action type and the opid.
* Each action is subsequently delayed by 20 milliseconds.
* @param {FillScriptActions} action
* @param {"click_on_opid" | "focus_by_opid" | "fill_by_opid"} action
* @param {string} opid
* @param {string} value
* @param {number} actionIndex
* @returns {Promise<void>}
* @private
*/
private runFillScriptAction = ([action, opid, value]: FillScript, actionIndex: number): void => {
private runFillScriptAction = (
[action, opid, value]: FillScript,
actionIndex: number
): Promise<void> => {
if (!opid || !this.autofillInsertActions[action]) {
return;
}
const delayActionsInMilliseconds = 20;
setTimeout(
() => this.autofillInsertActions[action]({ opid, value }),
delayActionsInMilliseconds * actionIndex
return new Promise((resolve) =>
setTimeout(() => {
this.autofillInsertActions[action]({ opid, value });
resolve();
}, delayActionsInMilliseconds * actionIndex)
);
};
@ -344,9 +353,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private simulateUserKeyboardEventInteractions(element: FormFieldElement): void {
[EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventType) =>
element.dispatchEvent(new KeyboardEvent(eventType, { bubbles: true }))
);
const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP];
for (let index = 0; index < simulatedKeyboardEvents.length; index++) {
element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true }));
}
}
/**
@ -356,9 +366,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private simulateInputElementChangedEvent(element: FormFieldElement): void {
[EVENTS.INPUT, EVENTS.CHANGE].forEach((eventType) =>
element.dispatchEvent(new Event(eventType, { bubbles: true }))
);
const simulatedInputEvents = [EVENTS.INPUT, EVENTS.CHANGE];
for (let index = 0; index < simulatedInputEvents.length; index++) {
element.dispatchEvent(new Event(simulatedInputEvents[index], { bubbles: true }));
}
}
}

View File

@ -1,14 +1,20 @@
@import "~nord/src/sass/nord.scss";
$dark-icon-themes: "theme_dark", "theme_solarizedDark", "theme_nord";
$font-family-sans-serif: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
$font-size-base: 14px;
$text-color: #333333;
$text-color: #212529;
$muted-text-color: #6c747c;
$border-color: #ced4dc;
$border-color-dark: #ddd;
$border-radius: 3px;
$focus-outline-color: #1252a3;
$brand-primary: #175ddc;
$background-color: #f0f0f0;
$background-color: #ffffff;
$background-offset-color: #f0f0f0;
$solarizedDarkBase0: #839496;
$solarizedDarkBase03: #002b36;
@ -21,50 +27,62 @@ $solarizedDarkGreen: #859900;
$themes: (
light: (
textColor: $text-color,
mutedTextColor: #6d757e,
mutedTextColor: $muted-text-color,
backgroundColor: $background-color,
backgroundOffsetColor: $background-offset-color,
primaryColor: $brand-primary,
buttonPrimaryColor: $brand-primary,
textContrast: $background-color,
inputBorderColor: darken($border-color-dark, 7%),
inputBorderColor: darken($border-color-dark, 2.75%),
inputBackgroundColor: #ffffff,
borderColor: $border-color,
focusOutlineColor: $focus-outline-color,
),
dark: (
textColor: #ffffff,
mutedTextColor: #bac0ce,
backgroundColor: #2f343d,
backgroundOffsetColor: darken(#2f343d, 2.75%),
buttonPrimaryColor: #6f9df1,
primaryColor: #6f9df1,
textContrast: #2f343d,
inputBorderColor: #4c525f,
inputBackgroundColor: #2f343d,
borderColor: #4c525f,
focusOutlineColor: $focus-outline-color,
),
nord: (
textColor: $nord5,
mutedTextColor: $nord4,
backgroundColor: $nord1,
backgroundOffsetColor: darken($nord1, 2.75%),
buttonPrimaryColor: $nord8,
primaryColor: $nord9,
textContrast: $nord2,
inputBorderColor: $nord0,
inputBackgroundColor: $nord2,
borderColor: $nord0,
focusOutlineColor: $focus-outline-color,
),
solarizedDark: (
textColor: $solarizedDarkBase2,
// Muted uses main text color to avoid contrast issues
mutedTextColor: $solarizedDarkBase2,
backgroundColor: $solarizedDarkBase03,
backgroundOffsetColor: darken($solarizedDarkBase03, 2.75%),
buttonPrimaryColor: $solarizedDarkCyan,
primaryColor: $solarizedDarkGreen,
textContrast: $solarizedDarkBase02,
inputBorderColor: rgba($solarizedDarkBase2, 0.2),
inputBackgroundColor: $solarizedDarkBase01,
borderColor: $solarizedDarkBase2,
focusOutlineColor: $focus-outline-color,
),
);
@mixin themify($themes: $themes) {
@each $theme, $map in $themes {
html.theme_#{$theme} & {
.theme_#{$theme} & {
$theme-map: () !global;
@each $key, $submap in $map {
$value: map-get(map-get($themes, $theme), "#{$key}");

View File

@ -0,0 +1,28 @@
const AutofillOverlayElement = {
Button: "autofill-overlay-button",
List: "autofill-overlay-list",
} as const;
const AutofillOverlayPort = {
Button: "autofill-overlay-button-port",
List: "autofill-overlay-list-port",
} as const;
const RedirectFocusDirection = {
Current: "current",
Previous: "previous",
Next: "next",
} as const;
const AutofillOverlayVisibility = {
Off: 0,
OnButtonClick: 1,
OnFieldFocus: 2,
} as const;
export {
AutofillOverlayElement,
AutofillOverlayPort,
RedirectFocusDirection,
AutofillOverlayVisibility,
};

View File

@ -0,0 +1,19 @@
const logoIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none"><path fill="#175DDC" d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"/><path fill="#fff" d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"/></svg>';
const logoLockedIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><g clip-path="url(#a)"><path fill="#175DDC" d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"/><path fill="#fff" d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"/><circle cx="12.889" cy="12.889" r="4.889" fill="#F8F9FA"/><path fill="#555" d="M11.26 11.717h2.37v-.848c0-.313-.116-.58-.348-.8a1.17 1.17 0 0 0-.838-.332c-.327 0-.606.11-.838.332a1.066 1.066 0 0 0-.347.8v.848Zm3.851.424v2.546a.4.4 0 0 1-.13.3.44.44 0 0 1-.314.124h-4.445a.44.44 0 0 1-.315-.124.4.4 0 0 1-.13-.3V12.14a.4.4 0 0 1 .13-.3.44.44 0 0 1 .315-.124h.148v-.848c0-.542.204-1.008.612-1.397a2.044 2.044 0 0 1 1.462-.583c.568 0 1.056.194 1.463.583.408.39.611.855.611 1.397v.848h.149a.44.44 0 0 1 .315.124.4.4 0 0 1 .13.3Z"/></g><defs><clipPath id="a"><rect width="16" height="16" fill="#fff" rx="2"/></clipPath></defs></svg>';
const globeIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewBox="0 0 24 25" fill="none"><path fill="#777" fill-rule="evenodd" d="M18.026 17.842c-1.418 1.739-3.517 2.84-5.86 2.84a7.364 7.364 0 0 1-3.431-.848l.062-.15.062-.151.063-.157c.081-.203.17-.426.275-.646.133-.28.275-.522.426-.68.026-.028.101-.075.275-.115.165-.037.376-.059.629-.073.138-.008.288-.014.447-.02.399-.016.847-.034 1.266-.092.314-.044.566-.131.755-.271a.884.884 0 0 0 .352-.555c.037-.2.008-.392-.03-.543-.035-.135-.084-.264-.12-.355l-.01-.03a4.26 4.26 0 0 0-.145-.33c-.126-.264-.237-.497-.288-1.085-.03-.344.09-.73.251-1.138l.089-.22c.05-.123.1-.247.14-.355.064-.171.129-.375.129-.566a1.51 1.51 0 0 0-.134-.569 2.573 2.573 0 0 0-.319-.547c-.246-.323-.635-.669-1.093-.669-.44 0-1.006.169-1.487.368-.246.102-.48.216-.68.33-.192.111-.372.235-.492.359-.93.96-1.48 1.239-1.81 1.258-.277.017-.478-.15-.736-.525a9.738 9.738 0 0 1-.19-.29l-.006-.01a11.568 11.568 0 0 0-.198-.305 2.76 2.76 0 0 0-.521-.6 1.39 1.39 0 0 0-1.088-.314 8.302 8.302 0 0 1 1.987-3.936c.055.342.146.626.272.856.23.42.561.64.926.716.406.086.857-.061 1.26-.216.125-.047.248-.097.372-.147.309-.125.618-.25.947-.341.26-.072.581-.057.959.012.264.049.529.118.8.19l.36.091c.379.094.782.178 1.135.148.374-.032.733-.197.934-.623a.874.874 0 0 0 .024-.752c-.087-.197-.24-.355-.35-.47-.26-.267-.412-.427-.412-.685 0-.125.037-.2.09-.263a.982.982 0 0 1 .303-.211c.059-.03.119-.058.183-.089l.036-.016a3.79 3.79 0 0 0 .236-.118c.047-.026.098-.056.148-.093 1.936.747 3.51 2.287 4.368 4.249a7.739 7.739 0 0 0-.031-.004c-.38-.047-.738-.056-1.063.061-.34.123-.603.368-.817.74-.122.211-.284.43-.463.67l-.095.129c-.207.281-.431.595-.58.92-.15.326-.245.705-.142 1.103.104.397.387.738.837 1.036.099.065.225.112.314.145l.02.008c.108.04.195.074.268.117.07.042.106.08.124.114.017.03.037.087.022.206-.047.376-.069.73-.052 1.034.017.292.071.59.218.809.118.174.12.421.108.786v.01a2.46 2.46 0 0 0 .021.518.809.809 0 0 0 .15.35Zm1.357.059a9.654 9.654 0 0 0 1.62-5.386c0-5.155-3.957-9.334-8.836-9.334-4.88 0-8.836 4.179-8.836 9.334 0 3.495 1.82 6.543 4.513 8.142v.093h.161a8.426 8.426 0 0 0 4.162 1.098c2.953 0 5.568-1.53 7.172-3.882a.569.569 0 0 0 .048-.062l-.004-.003ZM8.152 19.495a43.345 43.345 0 0 1 .098-.238l.057-.142c.082-.205.182-.455.297-.698.143-.301.323-.624.552-.864.163-.172.392-.254.602-.302.219-.05.473-.073.732-.088.162-.01.328-.016.495-.023.386-.015.782-.03 1.168-.084.255-.036.392-.099.461-.15.06-.045.076-.084.083-.12a.534.534 0 0 0-.02-.223 2.552 2.552 0 0 0-.095-.278l-.01-.027a3.128 3.128 0 0 0-.104-.232c-.134-.282-.31-.65-.374-1.381-.046-.533.138-1.063.3-1.472.035-.09.069-.172.1-.249.046-.11.086-.21.123-.31.062-.169.083-.264.083-.312a.812.812 0 0 0-.076-.283 1.867 1.867 0 0 0-.23-.394c-.21-.274-.428-.408-.577-.408-.315 0-.788.13-1.246.32a5.292 5.292 0 0 0-.603.293 1.727 1.727 0 0 0-.347.244c-.936.968-1.641 1.421-2.235 1.457-.646.04-1.036-.413-1.31-.813-.07-.103-.139-.21-.203-.311l-.005-.007c-.064-.101-.125-.197-.188-.29a2.098 2.098 0 0 0-.387-.453.748.748 0 0 0-.436-.18c-.1-.006-.22.005-.365.046a8.707 8.707 0 0 0-.056.992c0 2.957 1.488 5.547 3.716 6.98Zm10.362-2.316.003-.192.002-.046c.01-.305.026-.786-.232-1.169-.036-.054-.082-.189-.096-.444-.014-.243.003-.55.047-.9a1.051 1.051 0 0 0-.105-.649.987.987 0 0 0-.374-.374 2.285 2.285 0 0 0-.367-.166h-.003a1.243 1.243 0 0 1-.205-.088c-.369-.244-.505-.46-.549-.629-.044-.168-.015-.364.099-.61.115-.25.297-.511.507-.796l.089-.12c.178-.239.368-.495.512-.745.152-.263.302-.382.466-.441.18-.065.416-.073.77-.03.142.018.275.04.397.063.274.837.423 1.736.423 2.671a8.45 8.45 0 0 1-1.384 4.665Zm-4.632-12.63a7.362 7.362 0 0 0-1.715-.201c-1.89 0-3.621.716-4.965 1.905.025.54.12.887.24 1.105.13.238.295.34.482.38.2.042.484-.027.905-.188l.328-.13c.32-.13.681-.275 1.048-.377.398-.111.833-.075 1.24 0 .289.053.59.132.871.205l.326.084c.383.094.694.151.932.13.216-.017.326-.092.395-.237.039-.083.027-.114.014-.144-.027-.062-.088-.136-.212-.264l-.043-.044c-.218-.222-.567-.578-.567-1.142 0-.305.101-.547.262-.734.137-.159.308-.267.46-.347Z" clip-rule="evenodd"/></svg>';
const lockIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 17 17" fill="none"><g clip-path="url(#a)"><path fill="#175DDC" d="M8.799 11.633a.68.68 0 0 0-.639.422.695.695 0 0 0-.054.264.682.682 0 0 0 .374.6v1.13a.345.345 0 1 0 .693 0v-1.17a.68.68 0 0 0 .315-.56.695.695 0 0 0-.204-.486.682.682 0 0 0-.485-.2Zm4.554-4.657h-7.11a.438.438 0 0 1-.406-.26A3.81 3.81 0 0 1 5.584 4.3c.112-.435.312-.842.588-1.195A3.196 3.196 0 0 1 7.19 2.25a3.468 3.468 0 0 1 3.225-.059A3.62 3.62 0 0 1 11.94 3.71l.327.59a.502.502 0 1 0 .885-.483l-.307-.552a4.689 4.689 0 0 0-2.209-2.078 4.466 4.466 0 0 0-3.936.185A4.197 4.197 0 0 0 5.37 2.49a4.234 4.234 0 0 0-.768 1.565 4.714 4.714 0 0 0 .162 2.682.182.182 0 0 1-.085.22.173.173 0 0 1-.082.02h-.353a1.368 1.368 0 0 0-1.277.842c-.07.168-.107.348-.109.53v7.1a1.392 1.392 0 0 0 .412.974 1.352 1.352 0 0 0 .974.394h9.117c.363.001.711-.142.97-.4a1.39 1.39 0 0 0 .407-.972v-7.1a1.397 1.397 0 0 0-.414-.973 1.368 1.368 0 0 0-.972-.396Zm.37 8.469a.373.373 0 0 1-.11.26.364.364 0 0 1-.26.107H4.246a.366.366 0 0 1-.26-.107.374.374 0 0 1-.11-.261V8.349a.374.374 0 0 1 .11-.26.366.366 0 0 1 .26-.108h9.116a.366.366 0 0 1 .37.367l-.008 7.097Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M.798.817h16v16h-16z"/></clipPath></defs></svg>';
const plusIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none"><g clip-path="url(#a)"><path fill="#175DDC" d="M15.222 7.914H8.963a.471.471 0 0 1-.34-.147.512.512 0 0 1-.142-.353V.99c0-.133-.05-.26-.14-.354a.471.471 0 0 0-.68 0 .51.51 0 0 0-.142.354v6.424c0 .132-.051.26-.142.353a.474.474 0 0 1-.34.147H.777a.47.47 0 0 0-.34.146.5.5 0 0 0-.14.354.522.522 0 0 0 .14.353.48.48 0 0 0 .34.147h6.26c.128 0 .25.052.34.146.09.094.142.221.142.354v6.576c0 .132.05.26.14.353a.471.471 0 0 0 .68 0 .512.512 0 0 0 .142-.353V9.414c0-.133.051-.26.142-.354a.474.474 0 0 1 .34-.146h6.26c.127 0 .25-.053.34-.147a.511.511 0 0 0 0-.707.472.472 0 0 0-.34-.146Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .49h16v16H0z"/></clipPath></defs></svg>';
const viewCipherIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><g clip-path="url(#a)"><path fill="#175DDC" d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .113h20v19.773H0z"/></clipPath></defs></svg>';
export { logoIcon, logoLockedIcon, globeIcon, lockIcon, plusIcon, viewCipherIcon };

View File

@ -0,0 +1,118 @@
import { logoIcon, logoLockedIcon } from "./svg-icons";
import {
buildSvgDomElement,
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
} from "./utils";
describe("buildSvgDomElement", () => {
it("returns an SVG DOM element", () => {
const builtSVG = buildSvgDomElement(logoIcon);
const builtSVGAriaVisible = buildSvgDomElement(logoLockedIcon, false);
expect(builtSVG.tagName).toEqual("svg");
expect(builtSVG.getAttribute("aria-hidden")).toEqual("true");
expect(builtSVGAriaVisible.tagName).toEqual("svg");
expect(builtSVGAriaVisible.getAttribute("aria-hidden")).toEqual("false");
});
});
describe("generateRandomCustomElementName", () => {
it("returns a randomized value", async () => {
let generatedValue = "";
expect(generatedValue).toHaveLength(0);
generatedValue = generateRandomCustomElementName();
expect(generatedValue.length).toBeGreaterThan(0);
});
});
describe("sendExtensionMessage", () => {
it("sends a message to the extention", () => {
const extensionMessageResponse = sendExtensionMessage("updateAutofillOverlayHidden", {
display: "none",
});
jest.spyOn(chrome.runtime, "sendMessage");
expect(chrome.runtime.sendMessage).toHaveBeenCalled();
expect(extensionMessageResponse).toEqual(Promise.resolve({}));
});
});
describe("setElementStyles", () => {
const passedRules = { backgroundColor: "hotpink", color: "cyan" };
const expectedCSSRuleString = "background-color: hotpink; color: cyan;";
const expectedImportantCSSRuleString =
"background-color: hotpink !important; color: cyan !important;";
it("sets the passed styles to the passed HTMLElement", async () => {
const domParser = new DOMParser();
const testDivDOM = domParser.parseFromString(
"<div>This is an unexciting div.</div>",
"text/html"
);
const testDiv = testDivDOM.querySelector("div");
expect(testDiv.getAttribute("style")).toEqual(null);
setElementStyles(testDiv, passedRules);
expect(testDiv.getAttribute("style")).toEqual(expectedCSSRuleString);
});
it("sets the passed styles with !important flag to the passed HTMLElement", () => {
const domParser = new DOMParser();
const testDivDOM = domParser.parseFromString(
"<div>This is an unexciting div.</div>",
"text/html"
);
const testDiv = testDivDOM.querySelector("div");
expect(testDiv.style.cssText).toEqual("");
setElementStyles(testDiv, passedRules, true);
expect(testDiv.style.cssText).toEqual(expectedImportantCSSRuleString);
});
it("makes no changes when no element is passed", () => {
const domParser = new DOMParser();
const testDivDOM = domParser.parseFromString(
"<div>This is an unexciting div.</div>",
"text/html"
);
const testDiv = testDivDOM.querySelector("div");
expect(testDiv.style.cssText).toEqual("");
setElementStyles(testDiv, passedRules);
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
setElementStyles(undefined, passedRules, true);
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
});
it("makes no changes when no CSS rules are passed", () => {
const domParser = new DOMParser();
const testDivDOM = domParser.parseFromString(
"<div>This is an unexciting div.</div>",
"text/html"
);
const testDiv = testDivDOM.querySelector("div");
expect(testDiv.style.cssText).toEqual("");
setElementStyles(testDiv, passedRules);
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
setElementStyles(testDiv, {}, true);
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
});
});

View File

@ -0,0 +1,111 @@
/**
* Generates a random string of characters that formatted as a custom element name.
*/
function generateRandomCustomElementName(): string {
const generateRandomChars = (length: number): string => {
const chars = "abcdefghijklmnopqrstuvwxyz";
const randomChars = [];
const randomBytes = new Uint8Array(length);
globalThis.crypto.getRandomValues(randomBytes);
for (let byteIndex = 0; byteIndex < randomBytes.length; byteIndex++) {
const byte = randomBytes[byteIndex];
randomChars.push(chars[byte % chars.length]);
}
return randomChars.join("");
};
const length = Math.floor(Math.random() * 5) + 8; // Between 8 and 12 characters
const numHyphens = Math.min(Math.max(Math.floor(Math.random() * 4), 1), length - 1); // At least 1, maximum of 3 hyphens
const hyphenIndices: number[] = [];
while (hyphenIndices.length < numHyphens) {
const index = Math.floor(Math.random() * (length - 1)) + 1;
if (!hyphenIndices.includes(index)) {
hyphenIndices.push(index);
}
}
hyphenIndices.sort((a, b) => a - b);
let randomString = "";
let prevIndex = 0;
for (let index = 0; index < hyphenIndices.length; index++) {
const hyphenIndex = hyphenIndices[index];
randomString = randomString + generateRandomChars(hyphenIndex - prevIndex) + "-";
prevIndex = hyphenIndex;
}
randomString += generateRandomChars(length - prevIndex);
return randomString;
}
/**
* Builds a DOM element from an SVG string.
*
* @param svgString - The SVG string to build the DOM element from.
* @param ariaHidden - Determines whether the SVG should be hidden from screen readers.
*/
function buildSvgDomElement(svgString: string, ariaHidden = true): HTMLElement {
const domParser = new DOMParser();
const svgDom = domParser.parseFromString(svgString, "image/svg+xml");
const domElement = svgDom.documentElement;
domElement.setAttribute("aria-hidden", `${ariaHidden}`);
return domElement;
}
/**
* Sends a message to the extension.
*
* @param command - The command to send.
* @param options - The options to send with the command.
*/
async function sendExtensionMessage(
command: string,
options: Record<string, any> = {}
): Promise<any | void> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(Object.assign({ command }, options), (response) => {
if (chrome.runtime.lastError) {
return;
}
resolve(response);
});
});
}
/**
* Sets CSS styles on an element.
*
* @param element - The element to set the styles on.
* @param styles - The styles to set on the element.
* @param priority - Determines whether the styles should be set as important.
*/
function setElementStyles(
element: HTMLElement,
styles: Partial<CSSStyleDeclaration>,
priority?: boolean
) {
if (!element || !styles || !Object.keys(styles).length) {
return;
}
for (const styleProperty in styles) {
element.style.setProperty(
styleProperty.replace(/([a-z])([A-Z])/g, "$1-$2"), // Convert camelCase to kebab-case
styles[styleProperty],
priority ? "important" : undefined
);
}
}
export {
generateRandomCustomElementName,
buildSvgDomElement,
sendExtensionMessage,
setElementStyles,
};

View File

@ -121,6 +121,7 @@ import { BrowserOrganizationService } from "../admin-console/services/browser-or
import { BrowserPolicyService } from "../admin-console/services/browser-policy.service";
import ContextMenusBackground from "../autofill/background/context-menus.background";
import NotificationBackground from "../autofill/background/notification.background";
import OverlayBackground from "../autofill/background/overlay.background";
import TabsBackground from "../autofill/background/tabs.background";
import { CipherContextMenuHandler } from "../autofill/browser/cipher-context-menu-handler";
import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clicked-handler";
@ -236,6 +237,7 @@ export default class MainBackground {
private contextMenusBackground: ContextMenusBackground;
private idleBackground: IdleBackground;
private notificationBackground: NotificationBackground;
private overlayBackground: OverlayBackground;
private runtimeBackground: RuntimeBackground;
private tabsBackground: TabsBackground;
private webRequestBackground: WebRequestBackground;
@ -309,7 +311,7 @@ export default class MainBackground {
},
window
);
this.i18nService = new BrowserI18nService(BrowserApi.getUILanguage(window), this.stateService);
this.i18nService = new BrowserI18nService(BrowserApi.getUILanguage(), this.stateService);
this.encryptService = flagEnabled("multithreadDecryption")
? new MultithreadEncryptServiceImplementation(
this.cryptoFunctionService,
@ -655,8 +657,20 @@ export default class MainBackground {
this.stateService,
this.environmentService
);
this.tabsBackground = new TabsBackground(this, this.notificationBackground);
this.overlayBackground = new OverlayBackground(
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.settingsService,
this.stateService,
this.i18nService
);
this.tabsBackground = new TabsBackground(
this,
this.notificationBackground,
this.overlayBackground
);
if (!this.popupOnlyContext) {
const contextMenuClickedHandler = new ContextMenuClickedHandler(
(options) => this.platformUtilsService.copyToClipboard(options.text, { window: self }),
@ -738,6 +752,8 @@ export default class MainBackground {
this.configService.init();
this.twoFactorService.init();
await this.overlayBackground.init();
await this.tabsBackground.init();
if (!this.popupOnlyContext) {
this.contextMenusBackground?.init();

View File

@ -133,7 +133,8 @@ export default class RuntimeBackground {
case "triggerAutofillScriptInjection":
await this.autofillService.injectAutofillScripts(
sender,
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2)
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2),
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillOverlay)
);
break;
case "bgCollectPageDetails":

View File

@ -59,8 +59,12 @@
"webRequest",
"webRequestBlocking"
],
"optional_permissions": ["nativeMessaging"],
"optional_permissions": ["nativeMessaging", "privacy"],
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"sandbox": {
"pages": ["overlay/button.html", "overlay/list.html"],
"content_security_policy": "sandbox allow-scripts; script-src 'self'"
},
"commands": {
"_execute_browser_action": {
"suggested_key": {
@ -96,7 +100,10 @@
"content/fido2/page-script.js",
"notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png"
"images/icon38_locked.png",
"overlay/button.html",
"overlay/list.html",
"popup/fonts/*"
],
"applications": {
"gecko": {

View File

@ -66,12 +66,17 @@
"clipboardRead",
"clipboardWrite",
"idle",
"alarms"
"alarms",
"scripting"
],
"optional_permissions": ["nativeMessaging"],
"optional_permissions": ["nativeMessaging", "privacy"],
"host_permissions": ["http://*/*", "https://*/*"],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"sandbox": "sandbox allow-scripts; script-src 'self'"
},
"sandbox": {
"pages": ["overlay/button.html", "overlay/list.html"]
},
"commands": {
"_execute_action": {
@ -110,7 +115,10 @@
"content/webauthn/page-script.js",
"notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png"
"images/icon38_locked.png",
"overlay/button.html",
"overlay/list.html",
"popup/fonts/*"
],
"matches": ["<all_urls>"]
}

View File

@ -334,7 +334,7 @@ export class BrowserApi {
return process.env.ENV !== "production";
}
static getUILanguage(win: Window) {
static getUILanguage() {
return chrome.i18n.getUILanguage();
}
@ -369,11 +369,22 @@ export class BrowserApi {
if (BrowserApi.isWebExtensionsApi) {
return browser.permissions.request(permission);
}
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
chrome.permissions.request(permission, resolve);
});
}
/**
* Checks if the user has provided the given permissions to the extension.
*
* @param permissions - The permissions to check.
*/
static async permissionsGranted(permissions: string[]): Promise<boolean> {
return new Promise((resolve) =>
chrome.permissions.contains({ permissions }, (result) => resolve(result))
);
}
static getPlatformInfo(): Promise<browser.runtime.PlatformInfo | chrome.runtime.PlatformInfo> {
if (BrowserApi.isWebExtensionsApi) {
return browser.runtime.getPlatformInfo();
@ -423,4 +434,33 @@ export class BrowserApi {
});
});
}
/**
* Identifies if the browser autofill settings are overridden by the extension.
*/
static async browserAutofillSettingsOverridden(): Promise<boolean> {
const autofillAddressOverridden: boolean = await new Promise((resolve) =>
chrome.privacy.services.autofillAddressEnabled.get({}, (details) =>
resolve(details.levelOfControl === "controlled_by_this_extension" && !details.value)
)
);
const autofillCreditCardOverridden: boolean = await new Promise((resolve) =>
chrome.privacy.services.autofillCreditCardEnabled.get({}, (details) =>
resolve(details.levelOfControl === "controlled_by_this_extension" && !details.value)
)
);
return autofillAddressOverridden && autofillCreditCardOverridden;
}
/**
* Updates the browser autofill settings to the given value.
*
* @param value - Determines whether to enable or disable the autofill settings.
*/
static async updateDefaultBrowserAutofillSettings(value: boolean) {
chrome.privacy.services.autofillAddressEnabled.set({ value });
chrome.privacy.services.autofillCreditCardEnabled.set({ value });
}
}

View File

@ -54,7 +54,7 @@ const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise<void> => {
logoutCallback: () => Promise.resolve(),
},
i18nServiceOptions: {
systemLanguage: BrowserApi.getUILanguage(self),
systemLanguage: BrowserApi.getUILanguage(),
},
};
const logService = await logServiceFactory(cachedServices, opts);

View File

@ -279,7 +279,7 @@ export class UpdateBadge {
logoutCallback: () => Promise.reject("not implemented"),
},
i18nServiceOptions: {
systemLanguage: BrowserApi.getUILanguage(self),
systemLanguage: BrowserApi.getUILanguage(),
},
};
this.stateService = await stateServiceFactory(serviceCache, opts);

View File

@ -244,7 +244,7 @@ function getBgService<T>(service: keyof MainBackground) {
{
provide: I18nServiceAbstraction,
useFactory: (stateService: BrowserStateService) => {
return new BrowserI18nService(BrowserApi.getUILanguage(window), stateService);
return new BrowserI18nService(BrowserApi.getUILanguage(), stateService);
},
deps: [StateService],
},

View File

@ -11,7 +11,54 @@
<div class="right"></div>
</header>
<main tabindex="-1">
<div class="box tw-mt-4">
<div class="box-content">
<button
type="button"
class="box-content-row box-content-row-link box-content-row-flex"
(click)="commandSettings()"
>
<div class="row-main">{{ "autofillShortcut" | i18n }}</div>
<i class="bwi bwi-external-link bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
<div id="autofillKeyboardHelp" class="box-footer">
{{ autofillKeyboardHelperText }}
</div>
</div>
<ng-container *ngIf="isAutoFillOverlayFlagEnabled">
<div class="box">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<label for="autofill-overlay-settings">{{ "showAutoFillMenuOnFormFields" | i18n }}</label>
<select
id="autofill-overlay-settings"
name="autofill-overlay-settings"
[(ngModel)]="autoFillOverlayVisibility"
(change)="updateAutoFillOverlayVisibility()"
>
<option *ngFor="let o of autoFillOverlayVisibilityOptions" [ngValue]="o.value">
{{ o.name }}
</option>
</select>
</div>
</div>
</div>
</ng-container>
<div class="box tw-mt-4" *ngIf="canOverrideBrowserAutofillSetting">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="overrideBrowserAutofill">{{ "overrideBrowserAutoFillSettings" | i18n }}</label>
<input
id="overrideBrowserAutofill"
type="checkbox"
(change)="updateDefaultBrowserAutofillDisabled()"
[(ngModel)]="defaultBrowserAutofillDisabled"
/>
</div>
</div>
</div>
<div class="box tw-mt-4">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="autofill">{{ "enableAutoFillOnPageLoad" | i18n }}</label>
@ -82,19 +129,4 @@
{{ "defaultUriMatchDetectionDesc" | i18n }}
</div>
</div>
<div class="box">
<div class="box-content">
<button
type="button"
class="box-content-row box-content-row-link box-content-row-flex"
(click)="commandSettings()"
>
<div class="row-main">{{ "autofillShortcut" | i18n }}</div>
<i class="bwi bwi-external-link bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
<div id="autofillKeyboardHelp" class="box-footer">
{{ autofillKeyboardHelperText }}
</div>
</div>
</main>

View File

@ -1,10 +1,15 @@
import { Component, OnInit } from "@angular/core";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { UriMatchType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { DialogService } from "@bitwarden/components";
import { AutofillOverlayVisibility } from "../../autofill/utils/autofill-overlay.enum";
import { BrowserApi } from "../../platform/browser/browser-api";
@Component({
@ -12,6 +17,11 @@ import { BrowserApi } from "../../platform/browser/browser-api";
templateUrl: "autofill.component.html",
})
export class AutofillComponent implements OnInit {
protected canOverrideBrowserAutofillSetting = false;
protected defaultBrowserAutofillDisabled = false;
protected isAutoFillOverlayFlagEnabled = false;
protected autoFillOverlayVisibility: number;
protected autoFillOverlayVisibilityOptions: any[];
enableAutoFillOnPageLoad = false;
autoFillOnPageLoadDefault = false;
autoFillOnPageLoadOptions: any[];
@ -22,8 +32,25 @@ export class AutofillComponent implements OnInit {
constructor(
private stateService: StateService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService
private platformUtilsService: PlatformUtilsService,
private configService: ConfigServiceAbstraction,
private settingsService: SettingsService,
private dialogService: DialogService
) {
this.autoFillOverlayVisibilityOptions = [
{
name: i18nService.t("autofillOverlayVisibilityOff"),
value: AutofillOverlayVisibility.Off,
},
{
name: i18nService.t("autofillOverlayVisibilityOnFieldFocus"),
value: AutofillOverlayVisibility.OnFieldFocus,
},
{
name: i18nService.t("autofillOverlayVisibilityOnButtonClick"),
value: AutofillOverlayVisibility.OnButtonClick,
},
];
this.autoFillOnPageLoadOptions = [
{ name: i18nService.t("autoFillOnPageLoadYes"), value: true },
{ name: i18nService.t("autoFillOnPageLoadNo"), value: false },
@ -39,8 +66,17 @@ export class AutofillComponent implements OnInit {
}
async ngOnInit() {
this.enableAutoFillOnPageLoad = await this.stateService.getEnableAutoFillOnPageLoad();
this.canOverrideBrowserAutofillSetting = this.platformUtilsService.isChrome();
this.defaultBrowserAutofillDisabled = await this.browserAutofillSettingCurrentlyOverridden();
this.isAutoFillOverlayFlagEnabled = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.AutofillOverlay
);
this.autoFillOverlayVisibility =
(await this.settingsService.getAutoFillOverlayVisibility()) || AutofillOverlayVisibility.Off;
this.enableAutoFillOnPageLoad = await this.stateService.getEnableAutoFillOnPageLoad();
this.autoFillOnPageLoadDefault =
(await this.stateService.getAutoFillOnPageLoadDefault()) ?? true;
@ -51,6 +87,56 @@ export class AutofillComponent implements OnInit {
await this.setAutofillKeyboardHelperText(command);
}
async updateDefaultBrowserAutofillDisabled() {
const privacyPermissionGranted = await this.privacyPermissionGranted();
if (!this.defaultBrowserAutofillDisabled && !privacyPermissionGranted) {
return;
}
if (
!privacyPermissionGranted &&
!(await BrowserApi.requestPermission({ permissions: ["privacy"] }))
) {
await this.dialogService.openSimpleDialog({
title: { key: "extensionPrivacyPermissionNotGrantedTitle" },
content: { key: "extensionPrivacyPermissionNotGrantedDescription" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "warning",
});
this.defaultBrowserAutofillDisabled = false;
return;
}
await BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled);
}
async updateAutoFillOverlayVisibility() {
await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility);
if (
this.autoFillOverlayVisibility === AutofillOverlayVisibility.Off ||
!this.canOverrideBrowserAutofillSetting ||
(await this.browserAutofillSettingCurrentlyOverridden())
) {
return;
}
const permissionGranted = await this.privacyPermissionGranted();
const contentKey = permissionGranted
? "overrideBrowserAutofillDescription"
: "overrideBrowserAutofillPrivacyRequiredDescription";
await this.dialogService.openSimpleDialog({
title: { key: "overrideBrowserAutofillTitle" },
content: { key: contentKey },
acceptButtonText: { key: "turnOn" },
acceptAction: async () => await this.handleOverrideDialogAccept(),
cancelButtonText: { key: "ignore" },
type: "info",
});
}
async updateAutoFillOnPageLoad() {
await this.stateService.setEnableAutoFillOnPageLoad(this.enableAutoFillOnPageLoad);
}
@ -84,4 +170,25 @@ export class AutofillComponent implements OnInit {
BrowserApi.createNewTab("https://bitwarden.com/help/keyboard-shortcuts");
}
}
private handleOverrideDialogAccept = async () => {
this.defaultBrowserAutofillDisabled = true;
await this.updateDefaultBrowserAutofillDisabled();
};
async browserAutofillSettingCurrentlyOverridden() {
if (!this.canOverrideBrowserAutofillSetting) {
return false;
}
if (!(await this.privacyPermissionGranted())) {
return false;
}
return await BrowserApi.browserAutofillSettingsOverridden();
}
async privacyPermissionGranted(): Promise<boolean> {
return await BrowserApi.permissionsGranted(["privacy"]);
}
}

View File

@ -24,6 +24,7 @@
55E0377F2577FA6F00979016 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 55E037762577FA6F00979016 /* manifest.json */; };
55E037802577FA6F00979016 /* background.html in Resources */ = {isa = PBXBuildFile; fileRef = 55E037772577FA6F00979016 /* background.html */; };
55E037812577FA6F00979016 /* _locales in Resources */ = {isa = PBXBuildFile; fileRef = 55E037782577FA6F00979016 /* _locales */; };
55E037822577FA6F00979016 /* overlay in Resources */ = {isa = PBXBuildFile; fileRef = 55E037832577FA6F00979016 /* overlay */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -74,6 +75,7 @@
55E037762577FA6F00979016 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ../../../build/manifest.json; sourceTree = "<group>"; };
55E037772577FA6F00979016 /* background.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = background.html; path = ../../../build/background.html; sourceTree = "<group>"; };
55E037782577FA6F00979016 /* _locales */ = {isa = PBXFileReference; lastKnownFileType = folder; name = _locales; path = ../../../build/_locales; sourceTree = "<group>"; };
55E037832577FA6F00979016 /* overlay */ = {isa = PBXFileReference; lastKnownFileType = folder; name = overlay; path = ../../../build/overlay; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -160,6 +162,7 @@
55E037762577FA6F00979016 /* manifest.json */,
55E037772577FA6F00979016 /* background.html */,
55E037782577FA6F00979016 /* _locales */,
55E037832577FA6F00979016 /* overlay */,
);
name = Resources;
path = safari;
@ -270,6 +273,7 @@
55E0377C2577FA6F00979016 /* notification in Resources */,
55E0377E2577FA6F00979016 /* vendor.js in Resources */,
55E0377D2577FA6F00979016 /* content in Resources */,
55E037822577FA6F00979016 /* overlay in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -188,6 +188,7 @@ export class AddEditComponent extends BaseAddEditComponent {
}
if (this.inAddEditPopoutWindow()) {
this.messagingService.send("addEditCipherSubmitted");
await closeAddEditVaultItemPopout(1000);
return true;
}

View File

@ -18,6 +18,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
import { AutofillOverlayVisibility } from "../../../../autofill/utils/autofill-overlay.enum";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { VaultFilterService } from "../../../services/vault-filter.service";
@ -294,6 +295,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
private async setCallout() {
this.showHowToAutofill =
this.loginCiphers.length > 0 &&
(await this.stateService.getAutoFillOverlayVisibility()) === AutofillOverlayVisibility.Off &&
!(await this.stateService.getEnableAutoFillOnPageLoad()) &&
!(await this.stateService.getDismissedAutofillCallout());

View File

@ -33,19 +33,21 @@ import {
BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$,
} from "../../../fido2/browser-fido2-user-interface.service";
import { closeViewVaultItemPopout, VaultPopoutType } from "../../utils/vault-popout-window";
const BroadcasterSubscriptionId = "ChildViewComponent";
export const AUTOFILL_ID = "autofill";
export const SHOW_AUTOFILL_BUTTON = "show-autofill-button";
export const COPY_USERNAME_ID = "copy-username";
export const COPY_PASSWORD_ID = "copy-password";
export const COPY_VERIFICATIONCODE_ID = "copy-totp";
export const COPY_VERIFICATION_CODE_ID = "copy-totp";
type LoadAction =
| typeof AUTOFILL_ID
type CopyAction =
| typeof COPY_USERNAME_ID
| typeof COPY_PASSWORD_ID
| typeof COPY_VERIFICATIONCODE_ID;
| typeof COPY_VERIFICATION_CODE_ID;
type LoadAction = typeof AUTOFILL_ID | typeof SHOW_AUTOFILL_BUTTON | CopyAction;
@Component({
selector: "app-vault-view",
@ -57,6 +59,11 @@ export class ViewComponent extends BaseViewComponent {
tab: any;
senderTabId?: number;
loadAction?: LoadAction;
private static readonly copyActions = new Set([
COPY_USERNAME_ID,
COPY_PASSWORD_ID,
COPY_VERIFICATION_CODE_ID,
]);
uilocation?: "popout" | "popup" | "sidebar" | "tab";
loadPageDetailsTimeout: number;
inPopout = false;
@ -171,27 +178,7 @@ export class ViewComponent extends BaseViewComponent {
async load() {
await super.load();
await this.loadPageDetails();
switch (this.loadAction) {
case AUTOFILL_ID:
await this.fillCipher();
break;
case COPY_USERNAME_ID:
await this.copy(this.cipher.login.username, "username", "Username");
break;
case COPY_PASSWORD_ID:
await this.copy(this.cipher.login.password, "password", "Password");
break;
case COPY_VERIFICATIONCODE_ID:
await this.copy(this.totpCode, "verificationCodeTotp", "TOTP");
break;
default:
break;
}
if (this.inPopout && this.loadAction) {
setTimeout(() => this.close(), 1000);
}
await this.handleLoadAction();
}
async edit() {
@ -243,6 +230,8 @@ export class ViewComponent extends BaseViewComponent {
if (didAutofill) {
this.platformUtilsService.showToast("success", null, this.i18nService.t("autoFillSuccess"));
}
return didAutofill;
}
async fillCipherAndSave() {
@ -306,16 +295,18 @@ export class ViewComponent extends BaseViewComponent {
}
async close() {
// Would be refactored after rework is done on the windows popout service
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (this.inPopout && sessionData.isFido2Session) {
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
return;
}
if (this.inPopout && this.senderTabId) {
if (
BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.viewVaultItem) &&
this.senderTabId
) {
BrowserApi.focusTab(this.senderTabId);
window.close();
closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
return;
}
@ -324,11 +315,9 @@ export class ViewComponent extends BaseViewComponent {
private async loadPageDetails() {
this.pageDetails = [];
this.tab = await BrowserApi.getTabFromCurrentWindow();
if (this.senderTabId) {
this.tab = await BrowserApi.getTab(this.senderTabId);
}
this.tab = this.senderTabId
? await BrowserApi.getTab(this.senderTabId)
: await BrowserApi.getTabFromCurrentWindow();
if (!this.tab) {
return;
@ -381,4 +370,34 @@ export class ViewComponent extends BaseViewComponent {
return true;
}
private async handleLoadAction() {
if (!this.loadAction || this.loadAction === SHOW_AUTOFILL_BUTTON) {
return;
}
let loadActionSuccess = false;
if (this.loadAction === AUTOFILL_ID) {
loadActionSuccess = await this.fillCipher();
}
if (ViewComponent.copyActions.has(this.loadAction)) {
const { username, password } = this.cipher.login;
const copyParams: Record<CopyAction, Record<string, string>> = {
[COPY_USERNAME_ID]: { value: username, type: "username", name: "Username" },
[COPY_PASSWORD_ID]: { value: password, type: "password", name: "Password" },
[COPY_VERIFICATION_CODE_ID]: {
value: this.totpCode,
type: "verificationCodeTotp",
name: "TOTP",
},
};
const { value, type, name } = copyParams[this.loadAction as CopyAction];
loadActionSuccess = await this.copy(value, type, name);
}
if (this.inPopout) {
setTimeout(() => this.close(), loadActionSuccess ? 1000 : 0);
}
}
}

View File

@ -35,9 +35,9 @@ describe("VaultPopoutWindow", () => {
});
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/view-cipher?cipherId=cipherId&senderTabId=undefined&action=action",
"popup/index.html#/view-cipher?uilocation=popout&cipherId=cipherId&action=action",
{
singleActionKey: `${VaultPopoutType.vaultItemPasswordReprompt}_cipherId`,
singleActionKey: `${VaultPopoutType.viewVaultItem}_cipherId`,
senderWindowId: 1,
forceCloseExistingWindows: true,
}
@ -48,11 +48,11 @@ describe("VaultPopoutWindow", () => {
describe("openAddEditVaultItemPopout", () => {
it("opens a popout window that facilitates adding a vault item", async () => {
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://tacos.com" })
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" })
);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/edit-cipher?uilocation=popout&uri=https://tacos.com",
"popup/index.html#/edit-cipher?uilocation=popout&uri=https://jest-testing-website.com",
{
singleActionKey: VaultPopoutType.addEditVaultItem,
senderWindowId: 1,
@ -60,13 +60,16 @@ describe("VaultPopoutWindow", () => {
);
});
it("opens a popout window that facilitates adding a specific type of vault item", () => {
openAddEditVaultItemPopout(mock<chrome.tabs.Tab>({ windowId: 1, url: "https://tacos.com" }), {
it("opens a popout window that facilitates adding a specific type of vault item", async () => {
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
{
cipherType: CipherType.Identity,
});
}
);
expect(openPopoutSpy).toHaveBeenCalledWith(
`popup/index.html#/edit-cipher?uilocation=popout&type=${CipherType.Identity}&uri=https://tacos.com`,
`popup/index.html#/edit-cipher?uilocation=popout&type=${CipherType.Identity}&uri=https://jest-testing-website.com`,
{
singleActionKey: `${VaultPopoutType.addEditVaultItem}_${CipherType.Identity}`,
senderWindowId: 1,
@ -76,14 +79,14 @@ describe("VaultPopoutWindow", () => {
it("opens a popout window that facilitates editing a vault item", async () => {
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://tacos.com" }),
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
{
cipherId: "cipherId",
}
);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/edit-cipher?uilocation=popout&cipherId=cipherId&uri=https://tacos.com",
"popup/index.html#/edit-cipher?uilocation=popout&cipherId=cipherId&uri=https://jest-testing-website.com",
{
singleActionKey: `${VaultPopoutType.addEditVaultItem}_cipherId`,
senderWindowId: 1,
@ -93,14 +96,14 @@ describe("VaultPopoutWindow", () => {
});
describe("closeAddEditVaultItemPopout", () => {
it("closes the add/edit vault item popout window", () => {
closeAddEditVaultItemPopout();
it("closes the add/edit vault item popout window", async () => {
await closeAddEditVaultItemPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(VaultPopoutType.addEditVaultItem, 0);
});
it("closes the add/edit vault item popout window after a delay", () => {
closeAddEditVaultItemPopout(1000);
it("closes the add/edit vault item popout window after a delay", async () => {
await closeAddEditVaultItemPopout(1000);
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(
VaultPopoutType.addEditVaultItem,
@ -136,10 +139,10 @@ describe("VaultPopoutWindow", () => {
});
describe("closeFido2Popout", () => {
it("closes the fido2 popout window", () => {
it("closes the fido2 popout window", async () => {
const sessionId = "sessionId";
closeFido2Popout(sessionId);
await closeFido2Popout(sessionId);
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(
`${VaultPopoutType.fido2Popout}_${sessionId}`

View File

@ -1,13 +1,45 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
const VaultPopoutType = {
vaultItemPasswordReprompt: "vault_PasswordReprompt",
viewVaultItem: "vault_viewVaultItem",
addEditVaultItem: "vault_AddEditVaultItem",
fido2Popout: "vault_Fido2Popout",
} as const;
async function openViewVaultItemPopout(
senderTab: chrome.tabs.Tab,
cipherOptions: {
cipherId: string;
action: string;
forceCloseExistingWindows?: boolean;
}
) {
const { cipherId, action, forceCloseExistingWindows } = cipherOptions;
let promptWindowPath = "popup/index.html#/view-cipher?uilocation=popout";
if (cipherId) {
promptWindowPath += `&cipherId=${cipherId}`;
}
if (senderTab.id) {
promptWindowPath += `&senderTabId=${senderTab.id}`;
}
if (action) {
promptWindowPath += `&action=${action}`;
}
await BrowserPopupUtils.openPopout(promptWindowPath, {
singleActionKey: `${VaultPopoutType.viewVaultItem}_${cipherId}`,
senderWindowId: senderTab.windowId,
forceCloseExistingWindows,
});
}
async function closeViewVaultItemPopout(singleActionKey: string, delayClose = 0) {
await BrowserPopupUtils.closeSingleActionPopout(singleActionKey, delayClose);
}
/**
* Opens a popout window that facilitates re-prompting for
* the password of a vault item.
@ -22,18 +54,11 @@ async function openVaultItemPasswordRepromptPopout(
action: string;
}
) {
const { cipherId, action } = cipherOptions;
const promptWindowPath =
"popup/index.html#/view-cipher" +
`?cipherId=${cipherId}` +
`&senderTabId=${senderTab.id}` +
`&action=${action}`;
await BrowserPopupUtils.openPopout(promptWindowPath, {
singleActionKey: `${VaultPopoutType.vaultItemPasswordReprompt}_${cipherId}`,
senderWindowId: senderTab.windowId,
await openViewVaultItemPopout(senderTab, {
forceCloseExistingWindows: true,
...cipherOptions,
});
await BrowserApi.tabSendMessageData(senderTab, "bgVaultItemRepromptPopoutOpened");
}
/**
@ -121,6 +146,8 @@ async function closeFido2Popout(sessionId: string): Promise<void> {
export {
VaultPopoutType,
openViewVaultItemPopout,
closeViewVaultItemPopout,
openVaultItemPasswordRepromptPopout,
openAddEditVaultItemPopout,
closeAddEditVaultItemPopout,

View File

@ -20,10 +20,14 @@ const storage = {
const runtime = {
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
sendMessage: jest.fn(),
getManifest: jest.fn(),
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
onConnect: {
addListener: jest.fn(),
},
};
const contextMenus = {
@ -33,12 +37,29 @@ const contextMenus = {
const i18n = {
getMessage: jest.fn(),
getUILanguage: jest.fn(),
};
const tabs = {
executeScript: jest.fn(),
sendMessage: jest.fn(),
query: jest.fn(),
onActivated: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onReplaced: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onUpdated: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onRemoved: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
const scripting = {
@ -51,6 +72,18 @@ const windows = {
getCurrent: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
onFocusChanged: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
const port = {
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
postMessage: jest.fn(),
};
// set chrome
@ -62,4 +95,5 @@ global.chrome = {
tabs,
scripting,
windows,
port,
} as any;

View File

@ -107,6 +107,16 @@ const plugins = [
filename: "notification/bar.html",
chunks: ["notification/bar"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/pages/button/button.html",
filename: "overlay/button.html",
chunks: ["overlay/button"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/pages/list/list.html",
filename: "overlay/list.html",
chunks: ["overlay/list"],
}),
new CopyWebpackPlugin({
patterns: [
manifestVersion == 3
@ -135,7 +145,7 @@ const plugins = [
process: "process/browser.js",
}),
new webpack.SourceMapDevToolPlugin({
exclude: [/content\/.*/, /notification\/.*/],
exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/],
filename: "[file].map",
}),
...requiredPlugins,
@ -155,7 +165,8 @@ const mainConfig = {
"content/trigger-autofill-script-injection":
"./src/autofill/content/trigger-autofill-script-injection.ts",
"content/autofill": "./src/autofill/content/autofill.js",
"content/autofill-init": "./src/autofill/content/autofill-init.ts",
"content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts",
"content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts",
"content/autofiller": "./src/autofill/content/autofiller.ts",
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
@ -163,13 +174,15 @@ const mainConfig = {
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",
"overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts",
"overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts",
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts",
},
optimization: {
minimize: ENV !== "development",
minimizer: [
new TerserPlugin({
exclude: [/content\/.*/, /notification\/.*/],
exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/],
terserOptions: {
// Replicate Angular CLI behaviour
compress: {

View File

@ -10,26 +10,9 @@ import {
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
/**
* Provides a mapping from supported card brands to
* the filenames of icon that should be present in images/cards folder of clients.
*/
const cardIcons: Record<string, string> = {
Visa: "card-visa",
Mastercard: "card-mastercard",
Amex: "card-amex",
Discover: "card-discover",
"Diners Club": "card-diners-club",
JCB: "card-jcb",
Maestro: "card-maestro",
UnionPay: "card-union-pay",
RuPay: "card-ru-pay",
};
@Component({
selector: "app-vault-icon",
templateUrl: "icon.component.html",
@ -61,73 +44,6 @@ export class IconComponent implements OnInit {
this.data$ = combineLatest([
this.settingsService.disableFavicon$.pipe(distinctUntilChanged()),
this.cipher$.pipe(filter((c) => c !== undefined)),
]).pipe(
map(([disableFavicon, cipher]) => {
const imageEnabled = !disableFavicon;
let image = undefined;
let fallbackImage = "";
let icon = undefined;
switch (cipher.type) {
case CipherType.Login:
icon = "bwi-globe";
if (cipher.login.uri) {
let hostnameUri = cipher.login.uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
icon = "bwi-android";
image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
icon = "bwi-apple";
image = null;
} else if (
imageEnabled &&
hostnameUri.indexOf("://") === -1 &&
hostnameUri.indexOf(".") > -1
) {
hostnameUri = "http://" + hostnameUri;
isWebsite = true;
} else if (imageEnabled) {
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (imageEnabled && isWebsite) {
try {
image = iconsUrl + "/" + Utils.getHostname(hostnameUri) + "/icon.png";
fallbackImage = "images/bwi-globe.png";
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null.
}
}
} else {
image = null;
}
break;
case CipherType.SecureNote:
icon = "bwi-sticky-note";
break;
case CipherType.Card:
icon = "bwi-credit-card";
if (imageEnabled && cipher.card.brand in cardIcons) {
icon = "credit-card-icon " + cardIcons[cipher.card.brand];
}
break;
case CipherType.Identity:
icon = "bwi-id-card";
break;
default:
break;
}
return {
imageEnabled,
image,
fallbackImage,
icon,
};
})
);
]).pipe(map(([disableFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, disableFavicon)));
}
}

View File

@ -10,5 +10,7 @@ export abstract class SettingsService {
getEquivalentDomains: (url: string) => Set<string>;
setDisableFavicon: (value: boolean) => Promise<any>;
getDisableFavicon: () => boolean;
setAutoFillOverlayVisibility: (value: number) => Promise<void>;
getAutoFillOverlayVisibility: () => Promise<number>;
clear: (userId?: string) => Promise<void>;
}

View File

@ -4,6 +4,7 @@ export enum FeatureFlag {
TrustedDeviceEncryption = "trusted-device-encryption",
PasswordlessLogin = "passwordless-login",
AutofillV2 = "autofill-v2",
AutofillOverlay = "autofill-overlay",
BrowserFilelessImport = "browser-fileless-import",
ItemShare = "item-share",
FlexibleCollections = "flexible-collections",

View File

@ -285,6 +285,8 @@ export abstract class StateService<T extends Account = Account> {
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
getAutoFillOverlayVisibility: (options?: StorageOptions) => Promise<number>;
setAutoFillOverlayVisibility: (value: number, options?: StorageOptions) => Promise<void>;
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;

View File

@ -40,4 +40,5 @@ export class GlobalState {
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
autoFillOverlayVisibility?: number;
}

View File

@ -1,6 +1,7 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { AutofillOverlayVisibility } from "../../../../../apps/browser/src/autofill/utils/autofill-overlay.enum";
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
@ -1526,6 +1527,27 @@ export class StateService<
);
}
async getAutoFillOverlayVisibility(options?: StorageOptions): Promise<number> {
return (
(
await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
)
)?.autoFillOverlayVisibility ?? AutofillOverlayVisibility.OnFieldFocus
);
}
async setAutoFillOverlayVisibility(value: number, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
globals.autoFillOverlayVisibility = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}
async getEnableAutoFillOnPageLoad(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))

View File

@ -74,6 +74,14 @@ export class SettingsService implements SettingsServiceAbstraction {
return this._disableFavicon.getValue();
}
async setAutoFillOverlayVisibility(value: number): Promise<void> {
return await this.stateService.setAutoFillOverlayVisibility(value);
}
async getAutoFillOverlayVisibility(): Promise<number> {
return await this.stateService.getAutoFillOverlayVisibility();
}
async clear(userId?: string): Promise<void> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._settings.next({});

View File

@ -0,0 +1,85 @@
import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
export function buildCipherIcon(
iconsServerUrl: string,
cipher: CipherView,
isFaviconDisabled: boolean
) {
const imageEnabled = !isFaviconDisabled;
let icon;
let image;
let fallbackImage = "";
const cardIcons: Record<string, string> = {
Visa: "card-visa",
Mastercard: "card-mastercard",
Amex: "card-amex",
Discover: "card-discover",
"Diners Club": "card-diners-club",
JCB: "card-jcb",
Maestro: "card-maestro",
UnionPay: "card-union-pay",
RuPay: "card-ru-pay",
};
switch (cipher.type) {
case CipherType.Login:
icon = "bwi-globe";
if (cipher.login.uri) {
let hostnameUri = cipher.login.uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
icon = "bwi-android";
image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
icon = "bwi-apple";
image = null;
} else if (
imageEnabled &&
hostnameUri.indexOf("://") === -1 &&
hostnameUri.indexOf(".") > -1
) {
hostnameUri = `http://${hostnameUri}`;
isWebsite = true;
} else if (imageEnabled) {
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (imageEnabled && isWebsite) {
try {
image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;
fallbackImage = "images/bwi-globe.png";
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null.
}
}
} else {
image = null;
}
break;
case CipherType.SecureNote:
icon = "bwi-sticky-note";
break;
case CipherType.Card:
icon = "bwi-credit-card";
if (imageEnabled && cipher.card.brand in cardIcons) {
icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`;
}
break;
case CipherType.Identity:
icon = "bwi-id-card";
break;
default:
break;
}
return {
imageEnabled,
image,
fallbackImage,
icon,
};
}

View File

@ -84,7 +84,7 @@ export class CipherView implements View, InitializerMetadata {
}
get subTitle(): string {
return this.item.subTitle;
return this.item?.subTitle;
}
get hasPasswordHistory(): boolean {
@ -124,7 +124,7 @@ export class CipherView implements View, InitializerMetadata {
}
get linkedFieldOptions() {
return this.item.linkedFieldOptions;
return this.item?.linkedFieldOptions;
}
linkedFieldValue(id: LinkedIdType) {

13
package-lock.json generated
View File

@ -64,6 +64,7 @@
"proper-lockfile": "4.1.2",
"qrious": "4.0.2",
"rxjs": "7.8.1",
"tabbable": "^6.2.0",
"tldts": "6.0.14",
"utf-8-validate": "5.0.10",
"zone.js": "0.12.0",
@ -113,6 +114,7 @@
"@types/zxcvbn": "4.4.1",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"@webcomponents/custom-elements": "^1.6.0",
"autoprefixer": "10.4.15",
"base64-loader": "1.0.0",
"buffer": "6.0.3",
@ -15271,6 +15273,12 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webcomponents/custom-elements": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz",
"integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==",
"dev": true
},
"node_modules/@webpack-cli/configtest": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz",
@ -37297,6 +37305,11 @@
"integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==",
"dev": true
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
},
"node_modules/tailwindcss": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",

View File

@ -77,6 +77,7 @@
"@types/zxcvbn": "4.4.1",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"@webcomponents/custom-elements": "^1.6.0",
"autoprefixer": "10.4.15",
"base64-loader": "1.0.0",
"buffer": "6.0.3",
@ -196,6 +197,7 @@
"proper-lockfile": "4.1.2",
"qrious": "4.0.2",
"rxjs": "7.8.1",
"tabbable": "^6.2.0",
"tldts": "6.0.14",
"utf-8-validate": "5.0.10",
"zone.js": "0.12.0",