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:
parent
a4b961aa0a
commit
b622c38c6f
@ -60,6 +60,7 @@ function dist(browserName, manifest) {
|
||||
function distFirefox() {
|
||||
return dist("firefox", (manifest) => {
|
||||
delete manifest.storage;
|
||||
delete manifest.sandbox;
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
@ -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 browser’s.",
|
||||
"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 browser’s. 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"
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
1316
apps/browser/src/autofill/background/overlay.background.spec.ts
Normal file
1316
apps/browser/src/autofill/background/overlay.background.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
766
apps/browser/src/autofill/background/overlay.background.ts
Normal file
766
apps/browser/src/autofill/background/overlay.background.ts
Normal 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;
|
238
apps/browser/src/autofill/background/tabs.background.spec.ts
Normal file
238
apps/browser/src/autofill/background/tabs.background.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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) {
|
||||
|
@ -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";
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
8
apps/browser/src/autofill/content/bootstrap-autofill.ts
Normal file
8
apps/browser/src/autofill/content/bootstrap-autofill.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import AutofillInit from "./autofill-init";
|
||||
|
||||
(function (windowContext) {
|
||||
if (!windowContext.bitwardenAutofillInit) {
|
||||
windowContext.bitwardenAutofillInit = new AutofillInit();
|
||||
windowContext.bitwardenAutofillInit.init();
|
||||
}
|
||||
})(window);
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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 };
|
@ -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"
|
||||
/>
|
||||
`;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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>
|
||||
`;
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
})();
|
12
apps/browser/src/autofill/overlay/pages/button/button.html
Normal file
12
apps/browser/src/autofill/overlay/pages/button/button.html
Normal 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>
|
36
apps/browser/src/autofill/overlay/pages/button/button.scss
Normal file
36
apps/browser/src/autofill/overlay/pages/button/button.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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/"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
})();
|
12
apps/browser/src/autofill/overlay/pages/list/list.html
Normal file
12
apps/browser/src/autofill/overlay/pages/list/list.html
Normal 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>
|
293
apps/browser/src/autofill/overlay/pages/list/list.scss
Normal file
293
apps/browser/src/autofill/overlay/pages/list/list.scss
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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 };
|
@ -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
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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(() => {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}");
|
28
apps/browser/src/autofill/utils/autofill-overlay.enum.ts
Normal file
28
apps/browser/src/autofill/utils/autofill-overlay.enum.ts
Normal 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,
|
||||
};
|
19
apps/browser/src/autofill/utils/svg-icons.ts
Normal file
19
apps/browser/src/autofill/utils/svg-icons.ts
Normal 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 };
|
118
apps/browser/src/autofill/utils/utils.spec.ts
Normal file
118
apps/browser/src/autofill/utils/utils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
111
apps/browser/src/autofill/utils/utils.ts
Normal file
111
apps/browser/src/autofill/utils/utils.ts
Normal 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,
|
||||
};
|
@ -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();
|
||||
|
@ -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":
|
||||
|
@ -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": {
|
||||
|
@ -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>"]
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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],
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -188,6 +188,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
}
|
||||
|
||||
if (this.inAddEditPopoutWindow()) {
|
||||
this.messagingService.send("addEditCipherSubmitted");
|
||||
await closeAddEditVaultItemPopout(1000);
|
||||
return true;
|
||||
}
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}`
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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>;
|
||||
|
@ -40,4 +40,5 @@ export class GlobalState {
|
||||
disableAddLoginNotification?: boolean;
|
||||
disableChangedPasswordNotification?: boolean;
|
||||
disableContextMenuItem?: boolean;
|
||||
autoFillOverlayVisibility?: number;
|
||||
}
|
||||
|
@ -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())))
|
||||
|
@ -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({});
|
||||
|
85
libs/common/src/vault/icon/build-cipher-icon.ts
Normal file
85
libs/common/src/vault/icon/build-cipher-icon.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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
13
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user