From e973d72b0170a9fe40a7f4a0ac383c34bc84e2f0 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Mon, 15 Jul 2024 11:57:21 -0500 Subject: [PATCH] [PM-5189] Fix issues with inline menu rendering in iframes and SPA websites (#8431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [PM-8027] Working through jest tests for the InlineMenuFieldQualificationService * [PM-8027] Finalization of Jest test for the implementation * [PM-5189] Fixing existing jest tests before undergoing larger scale rework of tests * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Working through jest tests for OverlayBackground * [PM-5189] Working through jest tests for OverlayBackground * [PM-5189] Reworking how we handle updating ciphers on unlock and updating reference to auth status to use observable * [PM-5189] Fixing issue with how we remove the inline menu when a field is populated * [PM-5189] Fixing issue with programmatic redirection of the inlne menu * [PM-5189] Fixing issue with programmatic redirection of the inlne menu * [PM-5189] Adjusting how we handle fade out of the inline menu element * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Implementing jest tests for the OverlayBackground * [PM-5189] Fixing a weird side issue that appears when a frame within the page triggers a reposition after the inline menu has been built * [PM-5189] Fixing a weird side issue that appears when a frame within the page triggers a reposition after the inline menu has been built * [PM-5189] Fixing a weird side issue that appears when a frame within the page triggers a reposition after the inline menu has been built * [PM-8027] Fixing a typo * [PM-8027] Incorporating a feature flag to allow us to fallback to the basic inline menu fielld qualification method if needed * [PM-8027] Incorporating a feature flag to allow us to fallback to the basic inline menu fielld qualification method if needed * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Adding jest tests for added methods in AutofillInit * [PM-5189] Refactoring implementation * [PM-5189] Implementing jest tests for the CollectAutofillContentService * [PM-5189] Implementing jest tests for the AutofillOverlayContentService * [PM-5189] Working through jest tests for the AutofillOverlayContentService * [PM-5189] Working through jest tests for the AutofillOverlayContentService * [PM-5189] Working through jest tests for the AutofillOverlayContentService * [PM-5189] Working through jest tests for the AutofillOverlayContentService * [PM-5189] Working through jest tests for the AutofillOverlayContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuIframeServce * [PM-5189] Fixing a typo * [PM-5189] Fixing a typo * [PM-5189] Correcting typing information * [PM-5189] Fixing some typos * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation0 * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Implementing jest tests for AutofillInlineMenuContentService * [PM-5189] Fixing an issue found with iframe service * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Refactoring implementation * [PM-5189] Removing TODO message * [PM-5189] Increasing the time we delay the closure of the inline menu * [PM-5189] Fixing an issue with how we handle closing the inline menu after a programmtic redirection * [PM-5189] Removing unnecessary property * [PM-5189] Removing unnecessary property * [PM-5189] Fixing an issue with how scroll events trigger a reposition of the inline menu when the field is not focused; * [PM-5189] Implementing a set threshold for the maximum depth for which we are willing to calculate sub frame offsets * [PM-5189] Implementing a set threshold for the maximum depth for which we are willing to calculate sub frame offsets * [PM-5189] Implementing a set threshold for the maximum depth for which we are willing to calculate sub frame offsets * [PM-5189] Implementing a set threshold for the maximum depth for which we are willing to calculate sub frame offsets * [PM-5189] Fixing jest tests * [PM-5189] Implementing a methodology for triggering subframe updates from layout-shift * [PM-5189] Implementing a methodology for triggering subframe updates from layout-shift * [PM-8027] Fixing issue with username fields not qualifyng as a valid login field if a viewable password field is not present * [PM-8027] Fixing issue with username fields not qualifyng as a valid login field if a viewable password field is not present * [PM-8027] Fixing an issue where a field that has no form and no visible password fields should be qualified if a single password field exists in the page * [PM-8027] Fixing an issue where a field that has no form and no visible password fields should be qualified if a single password field exists in the page * [PM-5189] Implementing a methodology for triggering subframe updates from layout-shift * [PM-5189] Implementing a methodology for triggering subframe updates from layout-shift * [PM-8869] Autofill features broken on Safari * [PM-8869] Autofill features broken on Safari * [PM-5189] Working through subFrameRecalculation approach * [PM-5189] Fixing an issue found within Safari * [PM-8027] Reverting flag from a fallback flag to an enhancement feature flag * [PM-8027] Fixing jest tests * [PM-5189] Reworking how we handle updating ciphers within nested sub frames * [PM-5189] Reworking how we handle updating ciphers within nested sub frames * [PM-5189] Reworking how we handle updating ciphers within nested sub frames * [PM-5189] Reworking how we handle updating ciphers within nested sub frames * [PM-5189] Fixing issue found in Safari with how the inline menu is re-positioned * [PM-5189] Fixing issue found in Safari with how the inline menu is re-positioned * [PM-5189] Fixing issue found in Safari with how the inline menu is re-positioned * [PM-5189] Fixing jest tests * [PM-5189] Fixing jest tests * [PM-5189] Refining how we handle fading in the inline menu elements * [PM-5189] Refining how we handle fading in the inline menu elements * [PM-5189] Refining how we handle fading in the inline menu elements * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Reworking how we handle updating the inline menu position * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * [PM-5189] Working through content script port improvement * Revert "[PM-5189] Working through content script port improvement" This reverts commit 857008413f768db6595203c6af890cebca9e2cf6. * Revert "[PM-5189] Working through content script port improvement" This reverts commit f219d7107085cdf5330a0b9df41c2e966938ef3c. * Revert "[PM-5189] Working through content script port improvement" This reverts commit f389263b644adb1b7886d2b397e223f0cbd374a4. * Revert "[PM-5189] Working through content script port improvement" This reverts commit 8a48e576e13e94b8003d57e610356af73e378a75. * Revert "[PM-5189] Working through content script port improvement" This reverts commit e30a1ebc5d2f36b8fbf3e92b13a6f7426e366e44. * Revert "[PM-5189] Working through content script port improvement" This reverts commit da357f46b3e8d062a37f420a47cadaddb35eecc2. * [PM-5189] Reverting content script port rework * [PM-5189] Fixing jest tests for AutofillOverlayContentService * [PM-5189] Adding documentation for the AutofillOverlayContentService * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Throttling how often sub frame calculations can be triggered from the focus in listener * [PM-5189] Finalizing jest tests for AutofillOverlayContentService * [PM-5189] Finalizing jest tests for AutofillOverlayContentService * [PM-5189] Finalizing jest tests for AutofillOverlayContentService * [PM-5189] Removing custom debounce method that is unused * [PM-5189] Removing custom debounce method that is unused * [PM-2857] Reworking how we handle invalidating cache when a tab chagne has occurred * [PM-5189] Fixing issues found within code review behind how we position elements * [PM-5189] Fixing issues found within code review behind how we position elements * [PM-5189] Fixing issues found within code review behind how we position elements * [PM-5189] Fixing issues found within code review behind how we position elements * [PM-5189] Fixing issues found within code review behind how we position elements * [PM-5189] Fixing issues found within code review behind how we position elements * [PM-5189] Adding jest tests for OverlayBackground methods * [PM-5189] Adding jest tests for OverlayContentService methods * [PM-5189] Working through further issues on positioning of inline menu * [PM-5189] Working through further issues on positioning of inline menu * [PM-5189] Working through further issues on positioning of inline menu * [PM-5189] Working through further issues on positioning of inline menu * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays * [PM-5189] Fixing an issue found when switching between open windows * [PM-5189] Fixing an issue found when switching between open windows * [PM-5189] Removing throttle from resize listeners within the content script * [PM-5189] Removing throttle from resize listeners within the content script * [PM-5189] Fixing issue within Safari relating to repositioning elements from outer frame * [PM-5189] Fixing issue within Safari relating to repositioning elements from outer frame * [PM-5189] Fixing issue within Safari relating to repositioning elements from outer frame * [PM-5189] Adding some documentation and adjust jest test for util method * [PM-5189] Reverting naming structure for OverlayBackground method * [PM-5189] Fixing a missed promise reference within OverlayBackground * [PM-5189] Removing throttle from resize listeners within the content script * Revert "[PM-5189] Removing throttle from resize listeners within the content script" This reverts commit 62cf0f8f24dcccd21883f07b78855b26660cccb8. * [PM-5189] Re-adding throttle and reducing delay * [PM-5189] Fixing an issue with onButton click settings not being respected when a reposition event occurs * [PM-5189] Adding a missing test to OverlayBackground * [PM-5189] Fixing an issue where we trigger a blur event when the inline menu is hovered, but the page takes focus away * [PM-9342] Inline menu does not show on username field for a form that has a password field with an invalid autocomplete value * [PM-9342] Incorporating logic to handle multiple autocomplete values within a captured set of page details * [PM-9342] Incorporating logic to handle multiple autocomplete values within a captured set of page details * [PM-9342] Changing logic for how we identify new password fields to reflect a more assertive qualification * [PM-9342] Adding feedback from code review * [PM-5189] Fixing an issue where the port key for an inline menu element could potentially be undefined if the window focus changes too quickly * [PM-5189] Fixing an issue where we can potentially show the inline menu incorrectly after a user switches account * [PM-5189] Fixing an issue where we can potentially show the inline menu incorrectly after a user switches account * [PM-5189] Fixing an issue where we can potentially show the inline menu incorrectly after a user switches account * [PM-5189] Fixing an issue where we can potentially show the inline menu incorrectly after a user switches account * [PM-9267] Implement feature flag for inline menu re-architecture (#9845) * [PM-9267] Implement Feature Flag for Inline Menu Re-Architecture * [PM-9267] Incorporating legacy OverlayBackground implementation * [PM-9267] Incorporating legacy overlay content scripts * [PM-9267] Incorporating legacy overlay content scripts * [PM-9267] Incorporating legacy overlay content scripts * [PM-9267] Incorporating legacy overlay content scripts * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Finalizing feature flag implementation * [PM-9267] Adjusting naming convention for page files * [PM-9267] Adjusting naming convention for page files * [PM-5189] Fixing an issue where we can potentially show the inline menu incorrectly after a user switches account * PM-4950 - Fix hint and verify delete components that had the data in the wrong place (#9877) * PM-4661: Add passkey.username as item.username (#9756) * Add incoming passkey.username as item.username * Driveby fix, was sending wrong username * added username to new-cipher too * Guarded the if-block * Update apps/browser/src/vault/popup/components/vault/add-edit.component.ts Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * Fixed broken test * fixed username on existing ciphers --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-4878: Add passkey information to items when signing in (#9835) * Added username to subtitle * Added subName to cipher * Moved subName to component * Update apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts Co-authored-by: SmithThe4th * Fixed double code and added comment * Added changeDetection: ChangeDetectionStrategy.OnPush as per review --------- Co-authored-by: SmithThe4th * [AC-2791] Members page - finish component library refactors (#9727) * Replace PlatformUtilsService with ToastService * Remove unneeded templates * Implement table filtering function * Move member-only methods from base class to subclass * Move utility functions inside new MemberTableDataSource * Rename PeopleComponent to MembersComponent * [deps] Platform: Update angular-cli monorepo to v16.2.14 (#9380) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * [PM-8789] Move desktop_native into subcrate (#9682) * Move desktop_native into subcrate * Add publish = false to crates * [PM-6394] remove policy evaluator cache (#9807) * [PM-9364] Copy for Aggregate auto-scaling invoices for Teams and Enterprise customers (#9875) * Change the seat adjustment message * Move changes from en_GB file to en file * revert changes in en_GB file * Add feature flag to the change * use user verification as a part of key rotation (#9722) * Add the ability for custom validation logic to be injected into `UserVerificationDialogComponent` (#8770) * Introduce `verificationType` * Update template to use `verificationType` * Implement a path for `verificationType = 'custom'` * Delete `clientSideOnlyVerification` * Update `EnrollMasterPasswordResetComponent` to include a server-side hash check * Better describe the custom scenerio through comments * Add an example of the custom verficiation scenerio * Move execution of verification function into try/catch * Migrate existing uses of `clientSideOnlyVerification` * Use generic type option instead of casting * Change "given" to "determined" in a comment * Restructure the `org-redirect` guard to be Angular 17+ compliant (#9552) * Document the `org-redirect` guard in code * Make assertions about the way the `org-redirect` guard should behave * Restructure the `org-redirect` guard to be Angular 17+ compliant * Convert data parameter to function parameter * Convert a data parameter to a function parameter that was missed * Pass redirect function to default organization route * don't initialize kdf with validators, do it on first set (#9754) * add testids for attachments (#9892) * Bug fix - error toast in 2fa (#9623) * Bug fix - error toast in 2fa * Bug fix - Yubikey code obscured * 2FA error fix * Restructure the `is-paid-org` guard to be Angular 17+ compliant (#9598) * Document that `is-paid-org` guard in code * Remove unused `MessagingService` dependency * Make assertions about the way the is-paid-org guard should behave * Restructure the `is-paid-org` guard to be Angular 17+ compliant * Random commit to get the build job moving * Undo previous commit * Bumped client version(s) (#9895) * [PM-9344] Clarify accepted user state (#9861) * Prefer `Needs confirmation` to `Accepted` display status This emphasizes that action is still required to complete setup. * Remove unused message * Bumped client version(s) (#9906) * Revert "Bumped client version(s) (#9906)" (#9907) This reverts commit 78c28297938eda53e7731fdf9f63d7baa7068d0d. * fix duo subscriptions and org vs individual duo setup (#9859) * [PM-5024] Migrate tax-info component (#9872) * Changes for the tax info migration * Return for invalid formgroup * Restructure the `org-permissions` guard to be Angular 17+ compliant (#9631) * Document the `org-permissions` guard in code * Restructure the `org-permissions` guard to be Angular 17+ compliant * Update the `org-permissions` guard to use `ToastService` * Simplify callback function sigantures * Remove unused test object * Fix updated route from merge * Restructure the `provider-permissions` guard to be Angular 17+ compliant (#9609) * Document the `provider-permissions` guard in code * Restructure the `provider-permissions` guard to be Angular 17+ compliant * [deps] Platform: Update @types/argon2-browser to v1.18.4 (#8180) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Bumped client version(s) (#9914) * [PM-7162] Cipher Form - Item Details (#9758) * [PM-7162] Fix weird angular error regarding disabled component bit-select * [PM-7162] Introduce CipherFormConfigService and related types * [PM-7162] Introduce CipherFormService * [PM-7162] Introduce the Item Details section component and the CipherFormContainer interface * [PM-7162] Introduce the CipherForm component * [PM-7162] Add strongly typed QueryParams to the add-edit-v2.component * [PM-7162] Export CipherForm from Vault Lib * [PM-7162] Use the CipherForm in Browser AddEditV2 * [PM-7162] Introduce CipherForm storybook * [PM-7162] Remove VaultPopupListFilterService dependency from NewItemDropDownV2 component * [PM-7162] Add support for content projection of attachment button * [PM-7162] Fix typo * [PM-7162] Cipher form service cleanup * [PM-7162] Move readonly collection notice to bit-hint * [PM-7162] Refactor CipherFormConfig type to enforce required properties with Typescript * [PM-7162] Fix storybook after config changes * [PM-7162] Use new add-edit component for clone route * [deps]: Update @yao-pkg/pkg to ^5.12.0 (#9820) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Autosync the updated translations (#9922) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> * Autosync the updated translations (#9923) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> * Autosync the updated translations (#9924) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> * [AC-2830] Unable to create a free organization (#9917) * Resolve the issue free org creation * Check that the taxForm is touched * [PM-7162] Fix broken getter when original cipher is null (#9927) * [PM-8525] Edit Card (#9901) * initial add of card details section * add card number * update card brand when the card number changes * add year and month fields * add security code field * hide number and security code by default * add `id` for all form fields * update select options to match existing options * make year input numerical * only display card details for card ciphers * use style to set input height * handle numerical values for year * update heading when a brand is available * remove unused ref * use cardview types for the form * fix numerical input type * disable card details when in partial-edit mode * remove hardcoded height * update types for formBuilder * [PM-9440] Fix: handle undefined value in migration 66 (#9908) * fix: handle undefined value in migration 66 * fix: the if-statement was typo * Rename "encryptionAlgorithm" to "hashAlgorithmForEncryption" for clarity (#9891) * [PM-7972] Account switching integration with "remember email" functionality (#9750) * add account switching logic to login email service * enforce boolean and fix desktop account switcher order * [PM-9442] Add tests for undefined state values and proper emulation of ElectronStorageService in tests (#9910) * fix: handle undefined value in migration 66 * fix: the if-statement was typo * feat: duplicate error behavior in fake storage service * feat: fix all migrations that were setting undefined values * feat: add test for disabled fingrint in migration 66 * fix: default single user state saving undefined value to state * revert: awaiting floating promise gonna fix this in a separate PR * Revert "feat: fix all migrations that were setting undefined values" This reverts commit 034713256cee9a8e164295c88157fe33d8372c81. * feat: automatically convert save to remove * Revert "fix: default single user state saving undefined value to state" This reverts commit 6c36da6ba52f6886d0de2b502b3aaff7f122c3a7. * [AC-2805] Consolidated Billing UI Updates (#9893) * Add empty state for invoices * Make cards on create client dialog tabbable * Add space in $ / month per member * Mute text, remove (Monthly) and right align menu on clients table * Made used seats account for all users and fixed column sort for used/remaining * Resize pricing cards * Rename assignedSeats to occupiedSeats * [PM-9460][deps] Tools: Update electron to v31 (#9921) * [deps] Tools: Update electron to v31 * Bump version in electron-builder --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith * [AC-1452] Restrict access to 'Organization Info' and 'Two-Step Login' settings pages with a permission check (#9483) * Guard Organization Info route - Owners only * Guard TwoFactor route - Owners only and Organization must be able to use 2FA * Update guards to use function syntax --------- Co-authored-by: Addison Beck * [PM-9437] Use CollectionAccessDetailsResponse type now that is always the type returned from the API (#9951) * Add required env variables to desktop native build script (#9869) * [AC-2676] Remove paging logic from GroupsComponent (#9705) * remove infinite scroll, use virtual scroll instead * use TableDataSource for search * allow sorting by name * replacing PlatformUtilsService.showToast with ToastService * misc FIXMEs * [PM-9441] Catch and log exceptions during migration (#9905) * feat: catch and log exceptions during migration * Revert "feat: catch and log exceptions during migration" This reverts commit d68733b7e58120298974b350e496bb3e0c9af0d2. * feat: use log service to log migration errors * Autosync the updated translations (#9972) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> * Autosync the updated translations (#9973) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> * Updated codeowners for new design system team (#9913) * Updated codeowners for new design system team. * Moved Angular and Bootstrap dependencies * Moved additional dependencies. * Updated ownership Co-authored-by: Will Martin --------- Co-authored-by: Will Martin * [SM-1016] Fix new access token dialog (#9918) * swap to bit-dialog title & subtitle * remove dialogRef.disableClose & use toastService * Add shared two-factor-options component (#9767) * Communicate the upcoming client vault privacy changes to MSPs (#9994) * Add a banner notification to the provider portal * Feature flag the banner * Move banner copy to messages.json * Allow for dismissing the banner * Auth/PM-7321 - Registration with Email Verification - Registration Finish Component Implementation (#9653) * PM-7321 - Temp add input password * PM-7321 - update input password based on latest PR changes to test. * PM-7321 - Progress on testing input password component + RegistrationFinishComponent checks * PM-7321 - more progress on registration finish. * PM-7321 - Wire up RegistrationFinishRequest model + AccountApiService abstraction + implementation changes for new method. * PM-7321 - WIP Registration Finish - wiring up request building and API call on submit. * PM-7321 - WIP registratin finish * PM-7321 - WIP on creating registration-finish service + web override to add org invite handling * PM-7321 - (1) Move web-registration-finish svc to web (2) Wire up exports (3) wire up RegistrationFinishComponent to call registration finish service * PM-7321 - Get CLI building * PM-7321 - Move all finish registration service and content to registration-finish feature folder. * PM-7321 - Fix RegistrationFinishService config * PM-7321 - RegistrationFinishComponent- handlePasswordFormSubmit - error handling WIP * PM-7321 - InputPasswordComp - Update to accept masterPasswordPolicyOptions as input instead of retrieving it as parent components in different scenarios will need to retrieve the policies differently (e.g., orgInvite token in registration vs direct call via org id post SSO on set password) * PM-7321 - Registration Finish - Add web specific logic for retrieving master password policies and passing them into the input password component. * PM-7321 - Registration Start - Send email via query param to registration finish page so it can create masterKey * PM-7321 - InputPassword comp - (1) Add loading input (2) Add email validation to submit logic. * PM-7321 - Registration Finish - Add submitting state and pass into input password so that the rest of the registration process keeps the child form disabled. * PM-7321 - Registration Finish - use validation service for error handling. * PM-7321 - All register routes must be dynamic and change if the feature flag changes. * PM-7321 - Test registration finish services. * PM-7321 - RegisterRouteService - Add comment documenting why the service exists. * PM-7321 - Add missing input password translations to browser & desktop * PM-7321 - WebRegistrationFinishSvc - apply PR feedback * [deps] Autofill: Update rimraf to v5.0.8 (#10008) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * [PM-9318] Fix username on protonpass import (#9889) * Fix username field used for ProtonPass import ProtonPass has changed their export format and userName is not itemEmail * Import additional field itemUsername --------- Co-authored-by: Daniel James Smith * [PM-8943] Update QRious script initialization in Authenticator two-factor provider (#9926) * create onload() for qrious as well as error messaging if QR code cannot be displayed * button and message updates and formpromise removal * load QR script async * rename and reorder methods * Delete Unused Bits of StateService (#9858) * Delete Unused Bits of StateService * Fix Tests * remove getBgService for auth request service (#10020) --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Co-authored-by: Anders Åberg Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: SmithThe4th Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García Co-authored-by: ✨ Audrey ✨ Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Co-authored-by: Jake Fink Co-authored-by: Addison Beck Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Co-authored-by: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Co-authored-by: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Co-authored-by: Matt Gibson Co-authored-by: Opeyemi Co-authored-by: Shane Melton Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andreas Coroiu Co-authored-by: Bernd Schoolmann Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Addison Beck Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> Co-authored-by: Will Martin Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Co-authored-by: Anders Åberg Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: SmithThe4th Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García Co-authored-by: ✨ Audrey ✨ Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Co-authored-by: Jake Fink Co-authored-by: Addison Beck Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Co-authored-by: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Co-authored-by: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Co-authored-by: Matt Gibson Co-authored-by: Opeyemi Co-authored-by: Shane Melton Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andreas Coroiu Co-authored-by: Bernd Schoolmann Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Addison Beck Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> Co-authored-by: Will Martin Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- .../abstractions/overlay.background.ts | 160 +- .../background/notification.background.ts | 6 +- .../background/overlay.background.spec.ts | 3161 ++++++++++------- .../autofill/background/overlay.background.ts | 1274 +++++-- .../background/tabs.background.spec.ts | 15 +- .../autofill/background/tabs.background.ts | 9 +- .../content/abstractions/autofill-init.ts | 36 +- .../autofill/content/autofill-init.spec.ts | 363 +- .../src/autofill/content/autofill-init.ts | 197 +- .../content/bootstrap-autofill-overlay.ts | 18 +- .../overlay.background.deprecated.ts | 124 + .../overlay.background.deprecated.spec.ts | 1463 ++++++++ .../overlay.background.deprecated.ts | 798 +++++ .../abstractions/autofill-init.deprecated.ts | 41 + .../content/autofill-init.deprecated.spec.ts | 604 ++++ .../content/autofill-init.deprecated.ts | 310 ++ .../bootstrap-legacy-autofill-overlay.ts | 14 + .../autofill-overlay-button.deprecated.ts} | 0 ...fill-overlay-iframe.service.deprecated.ts} | 0 .../autofill-overlay-list.deprecated.ts} | 2 +- ...tofill-overlay-page-element.deprecated.ts} | 4 +- ...ay-iframe.service.deprecated.spec.ts.snap} | 2 +- ...-overlay-button-iframe.deprecated.spec.ts} | 2 +- ...ofill-overlay-button-iframe.deprecated.ts} | 4 +- ...overlay-iframe-element.deprecated.spec.ts} | 6 +- ...fill-overlay-iframe-element.deprecated.ts} | 2 +- ...overlay-iframe.service.deprecated.spec.ts} | 10 +- ...fill-overlay-iframe.service.deprecated.ts} | 4 +- ...ll-overlay-list-iframe.deprecated.spec.ts} | 2 +- ...utofill-overlay-list-iframe.deprecated.ts} | 4 +- ...ll-overlay-button.deprecated.spec.ts.snap} | 0 ...utofill-overlay-button.deprecated.spec.ts} | 33 +- .../autofill-overlay-button.deprecated.ts} | 8 +- ...trap-autofill-overlay-button.deprecated.ts | 9 + .../overlay/pages/button/legacy-button.html} | 2 +- .../overlay/pages/button/legacy-button.scss | 36 + ...fill-overlay-list.deprecated.spec.ts.snap} | 0 .../autofill-overlay-list.deprecated.spec.ts} | 66 +- .../list/autofill-overlay-list.deprecated.ts} | 12 +- ...tstrap-autofill-overlay-list.deprecated.ts | 9 + .../overlay/pages/list/legacy-list.html | 12 + .../overlay/pages/list/legacy-list.scss | 293 ++ ...l-overlay-page-element.deprecated.spec.ts} | 11 +- ...tofill-overlay-page-element.deprecated.ts} | 4 +- .../autofill-overlay-content.service.ts | 37 + ...overlay-content.service.deprecated.spec.ts | 1743 +++++++++ ...fill-overlay-content.service.deprecated.ts | 1133 ++++++ .../autofill/enums/autofill-overlay.enum.ts | 22 + ...ll-port.enums.ts => autofill-port.enum.ts} | 0 .../fido2/background/fido2.background.ts | 6 +- .../autofill-inline-menu-button.ts | 33 + .../autofill-inline-menu-container.ts | 31 + .../autofill-inline-menu-content.service.ts | 13 + .../autofill-inline-menu-iframe.service.ts | 30 + .../abstractions/autofill-inline-menu-list.ts | 30 + .../autofill-inline-menu-page-element.ts | 13 + ...tofill-inline-menu-content.service.spec.ts | 426 +++ .../autofill-inline-menu-content.service.ts | 437 +++ ...ll-inline-menu-iframe.service.spec.ts.snap | 11 + ...autofill-inline-menu-button-iframe.spec.ts | 27 + .../autofill-inline-menu-button-iframe.ts | 18 + ...utofill-inline-menu-iframe-element.spec.ts | 47 + .../autofill-inline-menu-iframe-element.ts | 21 + ...utofill-inline-menu-iframe.service.spec.ts | 521 +++ .../autofill-inline-menu-iframe.service.ts | 457 +++ .../autofill-inline-menu-list-iframe.spec.ts | 27 + .../autofill-inline-menu-list-iframe.ts | 23 + .../autofill-inline-menu-button.spec.ts.snap | 83 + .../autofill-inline-menu-button.spec.ts | 133 + .../button/autofill-inline-menu-button.ts | 126 + .../bootstrap-autofill-inline-menu-button.ts | 9 + .../inline-menu/pages/button/button.html | 12 + .../pages/button/button.scss | 8 +- .../autofill-inline-menu-list.spec.ts.snap | 536 +++ .../list/autofill-inline-menu-list.spec.ts | 491 +++ .../pages/list/autofill-inline-menu-list.ts | 626 ++++ .../bootstrap-autofill-inline-menu-list.ts | 9 + .../{ => inline-menu}/pages/list/list.html | 2 +- .../{ => inline-menu}/pages/list/list.scss | 18 +- .../autofill-inline-menu-container.spec.ts | 130 + .../autofill-inline-menu-container.ts | 179 + ...ootstrap-autofill-inline-menu-container.ts | 3 + .../pages/menu-container/menu-container.html | 10 + .../autofill-inline-menu-page-element.ts | 155 + .../bootstrap-autofill-overlay-button.ts | 9 - .../list/bootstrap-autofill-overlay-list.ts | 9 - .../autofill-overlay-content.service.ts | 48 +- ...nline-menu-field-qualifications.service.ts | 2 +- .../autofill/services/autofill-constants.ts | 2 +- .../autofill-overlay-content.service.spec.ts | 2076 +++++------ .../autofill-overlay-content.service.ts | 1231 ++++--- .../services/autofill.service.spec.ts | 37 +- .../src/autofill/services/autofill.service.ts | 28 +- .../collect-autofill-content.service.spec.ts | 43 +- .../collect-autofill-content.service.ts | 68 +- .../dom-element-visibility.service.ts | 7 + ...inline-menu-field-qualification.service.ts | 4 +- .../insert-autofill-content.service.spec.ts | 10 +- .../src/autofill/spec/autofill-mocks.ts | 73 +- .../src/autofill/spec/testing-utils.ts | 27 + .../autofill/utils/autofill-overlay.enum.ts | 17 - apps/browser/src/autofill/utils/index.spec.ts | 6 +- apps/browser/src/autofill/utils/index.ts | 158 +- .../browser/src/background/main.background.ts | 68 +- apps/browser/src/manifest.json | 10 +- apps/browser/src/manifest.v3.json | 10 +- .../src/popup/services/services.module.ts | 2 + apps/browser/webpack.config.js | 33 +- libs/common/src/autofill/constants/index.ts | 7 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 110 files changed, 16813 insertions(+), 3940 deletions(-) create mode 100644 apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts create mode 100644 apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts create mode 100644 apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts create mode 100644 apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts create mode 100644 apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts create mode 100644 apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts create mode 100644 apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts rename apps/browser/src/autofill/{overlay/abstractions/autofill-overlay-button.ts => deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts} (100%) rename apps/browser/src/autofill/{overlay/abstractions/autofill-overlay-iframe.service.ts => deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts} (100%) rename apps/browser/src/autofill/{overlay/abstractions/autofill-overlay-list.ts => deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts} (96%) rename apps/browser/src/autofill/{overlay/abstractions/autofill-overlay-page-element.ts => deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts} (89%) rename apps/browser/src/autofill/{overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap => deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap} (95%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-button-iframe.spec.ts => deprecated/overlay/iframe-content/autofill-overlay-button-iframe.deprecated.spec.ts} (97%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-button-iframe.ts => deprecated/overlay/iframe-content/autofill-overlay-button-iframe.deprecated.ts} (83%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-iframe-element.spec.ts => deprecated/overlay/iframe-content/autofill-overlay-iframe-element.deprecated.spec.ts} (92%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-iframe-element.ts => deprecated/overlay/iframe-content/autofill-overlay-iframe-element.deprecated.ts} (96%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-iframe.service.spec.ts => deprecated/overlay/iframe-content/autofill-overlay-iframe.service.deprecated.spec.ts} (98%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-iframe.service.ts => deprecated/overlay/iframe-content/autofill-overlay-iframe.service.deprecated.ts} (99%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-list-iframe.spec.ts => deprecated/overlay/iframe-content/autofill-overlay-list-iframe.deprecated.spec.ts} (97%) rename apps/browser/src/autofill/{overlay/iframe-content/autofill-overlay-list-iframe.ts => deprecated/overlay/iframe-content/autofill-overlay-list-iframe.deprecated.ts} (86%) rename apps/browser/src/autofill/{overlay/pages/button/__snapshots__/autofill-overlay-button.spec.ts.snap => deprecated/overlay/pages/button/__snapshots__/autofill-overlay-button.deprecated.spec.ts.snap} (100%) rename apps/browser/src/autofill/{overlay/pages/button/autofill-overlay-button.spec.ts => deprecated/overlay/pages/button/autofill-overlay-button.deprecated.spec.ts} (78%) rename apps/browser/src/autofill/{overlay/pages/button/autofill-overlay-button.ts => deprecated/overlay/pages/button/autofill-overlay-button.deprecated.ts} (95%) create mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/bootstrap-autofill-overlay-button.deprecated.ts rename apps/browser/src/autofill/{overlay/pages/button/button.html => deprecated/overlay/pages/button/legacy-button.html} (81%) create mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/legacy-button.scss rename apps/browser/src/autofill/{overlay/pages/list/__snapshots__/autofill-overlay-list.spec.ts.snap => deprecated/overlay/pages/list/__snapshots__/autofill-overlay-list.deprecated.spec.ts.snap} (100%) rename apps/browser/src/autofill/{overlay/pages/list/autofill-overlay-list.spec.ts => deprecated/overlay/pages/list/autofill-overlay-list.deprecated.spec.ts} (87%) rename apps/browser/src/autofill/{overlay/pages/list/autofill-overlay-list.ts => deprecated/overlay/pages/list/autofill-overlay-list.deprecated.ts} (98%) create mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts create mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/legacy-list.html create mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/legacy-list.scss rename apps/browser/src/autofill/{overlay/pages/shared/autofill-overlay-page-element.spec.ts => deprecated/overlay/pages/shared/autofill-overlay-page-element.deprecated.spec.ts} (96%) rename apps/browser/src/autofill/{overlay/pages/shared/autofill-overlay-page-element.ts => deprecated/overlay/pages/shared/autofill-overlay-page-element.deprecated.ts} (96%) create mode 100644 apps/browser/src/autofill/deprecated/services/abstractions/autofill-overlay-content.service.ts create mode 100644 apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.spec.ts create mode 100644 apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts create mode 100644 apps/browser/src/autofill/enums/autofill-overlay.enum.ts rename apps/browser/src/autofill/enums/{autofill-port.enums.ts => autofill-port.enum.ts} (100%) create mode 100644 apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-button.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-page-element.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/__snapshots__/autofill-inline-menu-iframe.service.spec.ts.snap create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-button-iframe.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-button-iframe.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-list-iframe.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-list-iframe.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/button/__snapshots__/autofill-inline-menu-button.spec.ts.snap create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/button/button.html rename apps/browser/src/autofill/overlay/{ => inline-menu}/pages/button/button.scss (75%) create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts rename apps/browser/src/autofill/overlay/{ => inline-menu}/pages/list/list.html (81%) rename apps/browser/src/autofill/overlay/{ => inline-menu}/pages/list/list.scss (92%) create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html create mode 100644 apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts delete mode 100644 apps/browser/src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts delete mode 100644 apps/browser/src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts delete mode 100644 apps/browser/src/autofill/utils/autofill-overlay.enum.ts diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index aa62194af5..462acb818b 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -2,17 +2,43 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { PageDetail } from "../../services/abstractions/autofill.service"; import { LockedVaultPendingNotificationsData } from "./notification.background"; -type WebsiteIconData = { +export type PageDetailsForTab = Record< + chrome.runtime.MessageSender["tab"]["id"], + Map +>; + +export type SubFrameOffsetData = { + top: number; + left: number; + url?: string; + frameId?: number; + parentFrameIds?: number[]; +} | null; + +export type SubFrameOffsetsForTab = Record< + chrome.runtime.MessageSender["tab"]["id"], + Map +>; + +export type WebsiteIconData = { imageEnabled: boolean; image: string; fallbackImage: string; icon: string; }; -type OverlayAddNewItemMessage = { +export type FocusedFieldData = { + focusedFieldStyles: Partial; + focusedFieldRects: Partial; + tabId?: number; + frameId?: number; +}; + +export type OverlayAddNewItemMessage = { login?: { uri?: string; hostname: string; @@ -21,112 +47,132 @@ type OverlayAddNewItemMessage = { }; }; -type OverlayBackgroundExtensionMessage = { - [key: string]: any; +export type CloseInlineMenuMessage = { + forceCloseInlineMenu?: boolean; + overlayElement?: string; +}; + +export type ToggleInlineMenuHiddenMessage = { + isInlineMenuHidden?: boolean; + setTransparentInlineMenu?: boolean; +}; + +export type OverlayBackgroundExtensionMessage = { command: string; + portKey?: string; tab?: chrome.tabs.Tab; sender?: string; details?: AutofillPageDetails; - overlayElement?: string; - display?: string; + isFieldCurrentlyFocused?: boolean; + isFieldCurrentlyFilling?: boolean; + isVisible?: boolean; + subFrameData?: SubFrameOffsetData; + focusedFieldData?: FocusedFieldData; + styles?: Partial; data?: LockedVaultPendingNotificationsData; -} & OverlayAddNewItemMessage; +} & OverlayAddNewItemMessage & + CloseInlineMenuMessage & + ToggleInlineMenuHiddenMessage; -type OverlayPortMessage = { +export type OverlayPortMessage = { [key: string]: any; command: string; direction?: string; - overlayCipherId?: string; + inlineMenuCipherId?: string; }; -type FocusedFieldData = { - focusedFieldStyles: Partial; - focusedFieldRects: Partial; - tabId?: number; -}; - -type OverlayCipherData = { +export type InlineMenuCipherData = { id: string; name: string; type: CipherType; reprompt: CipherRepromptType; favorite: boolean; - icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string }; + icon: WebsiteIconData; login?: { username: string }; card?: string; }; -type BackgroundMessageParam = { +export type BackgroundMessageParam = { message: OverlayBackgroundExtensionMessage; }; -type BackgroundSenderParam = { +export type BackgroundSenderParam = { sender: chrome.runtime.MessageSender; }; -type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; +export type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; -type OverlayBackgroundExtensionMessageHandlers = { +export type OverlayBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; - openAutofillOverlay: () => void; autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - getAutofillOverlayVisibility: () => void; - checkAutofillOverlayFocused: () => void; - focusAutofillOverlayList: () => void; - updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void; + triggerAutofillOverlayReposition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + checkIsInlineMenuCiphersPopulated: ({ sender }: BackgroundSenderParam) => void; updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + updateIsFieldCurrentlyFocused: ({ message }: BackgroundMessageParam) => void; + checkIsFieldCurrentlyFocused: () => boolean; + updateIsFieldCurrentlyFilling: ({ message }: BackgroundMessageParam) => void; + checkIsFieldCurrentlyFilling: () => boolean; + getAutofillInlineMenuVisibility: () => void; + openAutofillInlineMenu: () => void; + closeAutofillInlineMenu: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + checkAutofillInlineMenuFocused: ({ sender }: BackgroundSenderParam) => void; + focusAutofillInlineMenuList: () => void; + updateAutofillInlineMenuPosition: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => Promise; + updateAutofillInlineMenuElementIsVisibleStatus: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => void; + checkIsAutofillInlineMenuButtonVisible: () => void; + checkIsAutofillInlineMenuListVisible: () => void; + getCurrentTabFrameId: ({ sender }: BackgroundSenderParam) => number; + updateSubFrameData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + triggerSubFrameFocusInRebuild: ({ sender }: BackgroundSenderParam) => void; + destroyAutofillInlineMenuListeners: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => void; collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; unlockCompleted: ({ message }: BackgroundMessageParam) => void; + doFullSync: () => void; addedCipher: () => void; addEditCipherSubmitted: () => void; editedCipher: () => void; deletedCipher: () => void; }; -type PortMessageParam = { +export type PortMessageParam = { message: OverlayPortMessage; }; -type PortConnectionParam = { +export type PortConnectionParam = { port: chrome.runtime.Port; }; -type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; +export type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; -type OverlayButtonPortMessageHandlers = { +export type InlineMenuButtonPortMessageHandlers = { [key: string]: CallableFunction; - overlayButtonClicked: ({ port }: PortConnectionParam) => void; - closeAutofillOverlay: ({ port }: PortConnectionParam) => void; - forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; - overlayPageBlurred: () => void; - redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + triggerDelayedAutofillInlineMenuClosure: ({ port }: PortConnectionParam) => void; + autofillInlineMenuButtonClicked: ({ port }: PortConnectionParam) => void; + autofillInlineMenuBlurred: () => void; + redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + updateAutofillInlineMenuColorScheme: () => void; }; -type OverlayListPortMessageHandlers = { +export type InlineMenuListPortMessageHandlers = { [key: string]: CallableFunction; - checkAutofillOverlayButtonFocused: () => void; - forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; - overlayPageBlurred: () => void; + checkAutofillInlineMenuButtonFocused: () => void; + autofillInlineMenuBlurred: () => void; unlockVault: ({ port }: PortConnectionParam) => void; - fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void; + fillAutofillInlineMenuCipher: ({ message, port }: PortOnMessageHandlerParams) => void; addNewVaultItem: ({ port }: PortConnectionParam) => void; viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; - redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void; }; -interface OverlayBackground { +export interface OverlayBackground { init(): Promise; removePageDetails(tabId: number): void; - updateOverlayCiphers(): void; + updateOverlayCiphers(): Promise; } - -export { - WebsiteIconData, - OverlayBackgroundExtensionMessage, - OverlayPortMessage, - FocusedFieldData, - OverlayCipherData, - OverlayAddNewItemMessage, - OverlayBackgroundExtensionMessageHandlers, - OverlayButtonPortMessageHandlers, - OverlayListPortMessageHandlers, - OverlayBackground, -}; diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 179598a882..9e989b73e6 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -770,12 +770,12 @@ export default class NotificationBackground { ) => { const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; if (!handler) { - return; + return null; } const messageResponse = handler({ message, sender }); - if (!messageResponse) { - return; + if (typeof messageResponse === "undefined") { + return null; } Promise.resolve(messageResponse) diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 7be93b11e6..81a7754f84 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,102 +1,161 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; +import { BehaviorSubject } from "rxjs"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { - SHOW_AUTOFILL_BUTTON, AutofillOverlayVisibility, + SHOW_AUTOFILL_BUTTON, } from "@bitwarden/common/autofill/constants"; -import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { AutofillSettingsServiceAbstraction as AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DefaultDomainSettingsService, DomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EnvironmentService, Region, } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { - FakeStateProvider, FakeAccountService, + FakeStateProvider, mockAccountServiceWith, } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserPlatformUtilsService } from "../../platform/services/platform-utils/browser-platform-utils.service"; -import { AutofillService } from "../services/abstractions/autofill.service"; -import { - createAutofillPageDetailsMock, - createChromeTabMock, - createFocusedFieldDataMock, - createPageDetailMock, - createPortSpyMock, -} from "../spec/autofill-mocks"; -import { flushPromises, sendMockExtensionMessage, sendPortMessage } from "../spec/testing-utils"; import { AutofillOverlayElement, AutofillOverlayPort, + MAX_SUB_FRAME_DEPTH, RedirectFocusDirection, -} from "../utils/autofill-overlay.enum"; +} from "../enums/autofill-overlay.enum"; +import { AutofillService } from "../services/abstractions/autofill.service"; +import { + createChromeTabMock, + createAutofillPageDetailsMock, + createPortSpyMock, + createFocusedFieldDataMock, + createPageDetailMock, +} from "../spec/autofill-mocks"; +import { + flushPromises, + sendMockExtensionMessage, + sendPortMessage, + triggerPortOnConnectEvent, + triggerPortOnDisconnectEvent, + triggerPortOnMessageEvent, + triggerWebNavigationOnCommittedEvent, +} from "../spec/testing-utils"; -import OverlayBackground from "./overlay.background"; +import { + FocusedFieldData, + PageDetailsForTab, + SubFrameOffsetData, + SubFrameOffsetsForTab, +} from "./abstractions/overlay.background"; +import { OverlayBackground } from "./overlay.background"; describe("OverlayBackground", () => { const mockUserId = Utils.newGuid() as UserId; - const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); - const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + const sendResponse = jest.fn(); + let accountService: FakeAccountService; + let fakeStateProvider: FakeStateProvider; + let showFaviconsMock$: BehaviorSubject; let domainSettingsService: DomainSettingsService; - let buttonPortSpy: chrome.runtime.Port; - let listPortSpy: chrome.runtime.Port; - let overlayBackground: OverlayBackground; - const cipherService = mock(); - const autofillService = mock(); + let logService: MockProxy; + let cipherService: MockProxy; + let autofillService: MockProxy; let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; + let environmentMock$: BehaviorSubject; + let environmentService: MockProxy; + let inlineMenuVisibilityMock$: BehaviorSubject; + let autofillSettingsService: MockProxy; + let i18nService: MockProxy; + let platformUtilsService: MockProxy; + let selectedThemeMock$: BehaviorSubject; + let themeStateService: MockProxy; + let overlayBackground: OverlayBackground; + let portKeyForTabSpy: Record; + let pageDetailsForTabSpy: PageDetailsForTab; + let subFrameOffsetsSpy: SubFrameOffsetsForTab; + let getFrameDetailsSpy: jest.SpyInstance; + let tabsSendMessageSpy: jest.SpyInstance; + let tabSendMessageDataSpy: jest.SpyInstance; + let sendMessageSpy: jest.SpyInstance; + let getTabFromCurrentWindowIdSpy: jest.SpyInstance; + let getTabSpy: jest.SpyInstance; + let openUnlockPopoutSpy: jest.SpyInstance; + let buttonPortSpy: chrome.runtime.Port; + let buttonMessageConnectorSpy: chrome.runtime.Port; + let listPortSpy: chrome.runtime.Port; + let listMessageConnectorSpy: chrome.runtime.Port; - const environmentService = mock(); - environmentService.environment$ = new BehaviorSubject( - new CloudEnvironment({ - key: Region.US, - domain: "bitwarden.com", - urls: { icons: "https://icons.bitwarden.com/" }, - }), - ); - const autofillSettingsService = mock(); - const i18nService = mock(); - const platformUtilsService = mock(); - const themeStateService = mock(); - const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { + let getFrameCounter: number = 2; + async function initOverlayElementPorts(options = { initList: true, initButton: true }) { const { initList, initButton } = options; if (initButton) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); - buttonPortSpy = overlayBackground["overlayButtonPort"]; + triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["inlineMenuButtonPort"]; + + buttonMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ButtonMessageConnector); + triggerPortOnConnectEvent(buttonMessageConnectorSpy); } if (initList) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); - listPortSpy = overlayBackground["overlayListPort"]; + triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["inlineMenuListPort"]; + + listMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ListMessageConnector); + triggerPortOnConnectEvent(listMessageConnectorSpy); } return { buttonPortSpy, listPortSpy }; - }; + } beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + fakeStateProvider = new FakeStateProvider(accountService); + showFaviconsMock$ = new BehaviorSubject(true); domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService.showFavicons$ = showFaviconsMock$; + logService = mock(); + cipherService = mock(); + autofillService = mock(); activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; + environmentMock$ = new BehaviorSubject( + new CloudEnvironment({ + key: Region.US, + domain: "bitwarden.com", + urls: { icons: "https://icons.bitwarden.com/" }, + }), + ); + environmentService = mock(); + environmentService.environment$ = environmentMock$; + inlineMenuVisibilityMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus); + autofillSettingsService = mock(); + autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$; + i18nService = mock(); + platformUtilsService = mock(); + selectedThemeMock$ = new BehaviorSubject(ThemeType.Light); + themeStateService = mock(); + themeStateService.selectedTheme$ = selectedThemeMock$; overlayBackground = new OverlayBackground( + logService, cipherService, autofillService, authService, @@ -107,48 +166,528 @@ describe("OverlayBackground", () => { platformUtilsService, themeStateService, ); - - jest - .spyOn(overlayBackground as any, "getOverlayVisibility") - .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); - - themeStateService.selectedTheme$ = of(ThemeType.Light); - domainSettingsService.showFavicons$ = of(true); + portKeyForTabSpy = overlayBackground["portKeyForTab"]; + pageDetailsForTabSpy = overlayBackground["pageDetailsForTab"]; + subFrameOffsetsSpy = overlayBackground["subFrameOffsetsForTab"]; + getFrameDetailsSpy = jest.spyOn(BrowserApi, "getFrameDetails"); + getFrameDetailsSpy.mockImplementation((_details: chrome.webNavigation.GetFrameDetails) => { + getFrameCounter--; + return mock({ + parentFrameId: getFrameCounter, + }); + }); + tabsSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage"); + tabSendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData"); + sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); + getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); + getTabSpy = jest.spyOn(BrowserApi, "getTab"); + openUnlockPopoutSpy = jest.spyOn(overlayBackground as any, "openUnlockPopout"); void overlayBackground.init(); }); afterEach(() => { + getFrameCounter = 2; jest.clearAllMocks(); + jest.useRealTimers(); mockReset(cipherService); }); - describe("removePageDetails", () => { - it("removes the page details for a specific tab from the pageDetailsForTab object", () => { + describe("storing pageDetails", () => { + const tabId = 1; + + beforeEach(() => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ tab: createChromeTabMock({ id: tabId }), frameId: 0 }), + ); + }); + + it("stores the page details for the tab", () => { + expect(pageDetailsForTabSpy[tabId]).toBeDefined(); + }); + + describe("building sub frame offsets", () => { + beforeEach(() => { + tabsSendMessageSpy.mockResolvedValue( + mock({ + left: getFrameCounter, + top: getFrameCounter, + url: "url", + }), + ); + }); + + it("triggers a destruction of the inline menu listeners if the max frame depth is exceeded ", async () => { + getFrameCounter = MAX_SUB_FRAME_DEPTH + 1; + const tab = createChromeTabMock({ id: tabId }); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab, + frameId: 1, + }), + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "destroyAutofillInlineMenuListeners" }, + { frameId: 1 }, + ); + }); + + it("builds the offset values for a sub frame within the tab", async () => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab: createChromeTabMock({ id: tabId }), + frameId: 1, + }), + ); + await flushPromises(); + + expect(subFrameOffsetsSpy[tabId]).toStrictEqual( + new Map([[1, { left: 4, top: 4, url: "url", parentFrameIds: [0, 1] }]]), + ); + expect(pageDetailsForTabSpy[tabId].size).toBe(2); + }); + + it("skips building offset values for a previously calculated sub frame", async () => { + getFrameCounter = 0; + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab: createChromeTabMock({ id: tabId }), + frameId: 1, + }), + ); + await flushPromises(); + + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab: createChromeTabMock({ id: tabId }), + frameId: 1, + }), + ); + await flushPromises(); + + expect(getFrameDetailsSpy).toHaveBeenCalledTimes(1); + expect(subFrameOffsetsSpy[tabId]).toStrictEqual( + new Map([[1, { left: 0, top: 0, url: "url", parentFrameIds: [0] }]]), + ); + }); + + it("will attempt to build the sub frame offsets by posting window messages if a set of offsets is not returned", async () => { + const tab = createChromeTabMock({ id: tabId }); + const frameId = 1; + tabsSendMessageSpy.mockResolvedValue(null); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ tab, frameId }), + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { + command: "getSubFrameOffsetsFromWindowMessage", + subFrameId: frameId, + }, + { frameId }, + ); + expect(subFrameOffsetsSpy[tabId]).toStrictEqual(new Map([[frameId, null]])); + }); + + it("updates sub frame data that has been calculated using window messages", async () => { + const tab = createChromeTabMock({ id: tabId }); + const frameId = 1; + const subFrameData = mock({ frameId, left: 10, top: 10, url: "url" }); + tabsSendMessageSpy.mockResolvedValueOnce(null); + subFrameOffsetsSpy[tabId] = new Map([[frameId, null]]); + + sendMockExtensionMessage( + { command: "updateSubFrameData", subFrameData }, + mock({ tab, frameId }), + ); + await flushPromises(); + + expect(subFrameOffsetsSpy[tabId]).toStrictEqual(new Map([[frameId, subFrameData]])); + }); + }); + }); + + describe("removing pageDetails", () => { + it("removes the page details and port key for a specific tab from the pageDetailsForTab object", () => { const tabId = 1; - const frameId = 2; - overlayBackground["pageDetailsForTab"][tabId] = new Map([[frameId, createPageDetailMock()]]); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ tab: createChromeTabMock({ id: tabId }), frameId: 1 }), + ); + overlayBackground.removePageDetails(tabId); - expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); + expect(pageDetailsForTabSpy[tabId]).toBeUndefined(); + expect(portKeyForTabSpy[tabId]).toBeUndefined(); }); }); - describe("init", () => { - it("sets up the extension message listeners, get the overlay's visibility settings, and get the user's auth status", async () => { - overlayBackground["setupExtensionMessageListeners"] = jest.fn(); - overlayBackground["getOverlayVisibility"] = jest.fn(); - overlayBackground["getAuthStatus"] = jest.fn(); + describe("re-positioning the inline menu within sub frames", () => { + const tabId = 1; + const topFrameId = 0; + const middleFrameId = 10; + const middleAdjacentFrameId = 11; + const bottomFrameId = 20; + let tab: chrome.tabs.Tab; + let sender: MockProxy; - await overlayBackground.init(); + async function flushOverlayRepositionPromises() { + await flushPromises(); + jest.advanceTimersByTime(1150); + await flushPromises(); + } - expect(overlayBackground["setupExtensionMessageListeners"]).toHaveBeenCalled(); - expect(overlayBackground["getOverlayVisibility"]).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + beforeEach(() => { + jest.useFakeTimers(); + tab = createChromeTabMock({ id: tabId }); + sender = mock({ tab, frameId: middleFrameId }); + overlayBackground["focusedFieldData"] = mock({ + tabId, + frameId: bottomFrameId, + }); + subFrameOffsetsSpy[tabId] = new Map([ + [topFrameId, { left: 1, top: 1, url: "https://top-frame.com", parentFrameIds: [] }], + [ + middleFrameId, + { left: 2, top: 2, url: "https://middle-frame.com", parentFrameIds: [topFrameId] }, + ], + [ + middleAdjacentFrameId, + { + left: 3, + top: 3, + url: "https://middle-adjacent-frame.com", + parentFrameIds: [topFrameId], + }, + ], + [ + bottomFrameId, + { + left: 4, + top: 4, + url: "https://bottom-frame.com", + parentFrameIds: [topFrameId, middleFrameId], + }, + ], + ]); + tabsSendMessageSpy.mockResolvedValue( + mock({ + left: getFrameCounter, + top: getFrameCounter, + url: "url", + }), + ); + }); + + describe("triggerAutofillOverlayReposition", () => { + describe("checkShouldRepositionInlineMenu", () => { + let focusedFieldData: FocusedFieldData; + let repositionInlineMenuSpy: jest.SpyInstance; + + beforeEach(() => { + focusedFieldData = createFocusedFieldDataMock({ tabId }); + repositionInlineMenuSpy = jest.spyOn(overlayBackground as any, "repositionInlineMenu"); + }); + + describe("blocking a reposition of the overlay", () => { + it("blocks repositioning when the focused field data is not set", async () => { + overlayBackground["focusedFieldData"] = undefined; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender is from a different tab than the focused field", async () => { + const otherSender = mock({ frameId: 1, tab: { id: 2 } }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherSender, + ); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender frame is not a parent frame of the focused field", async () => { + focusedFieldData = createFocusedFieldDataMock({ tabId }); + const otherFrameSender = mock({ + tab, + frameId: middleAdjacentFrameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherFrameSender, + ); + sender.frameId = bottomFrameId; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + }); + + describe("allowing a reposition of the overlay", () => { + it("allows repositioning when the sender frame is for the focused field and the inline menu is visible, ", async () => { + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + sender, + ); + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "checkIsAutofillInlineMenuButtonVisible") { + return Promise.resolve(true); + } + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("repositionInlineMenu", () => { + beforeEach(() => { + overlayBackground["isFieldCurrentlyFocused"] = true; + }); + + it("closes the inline menu if the field is not focused", async () => { + overlayBackground["isFieldCurrentlyFocused"] = false; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "closeAutofillInlineMenu" }, + { frameId: 0 }, + ); + }); + + it("closes the inline menu if the focused field is not within the viewport", async () => { + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "checkIsMostRecentlyFocusedFieldWithinViewport") { + return Promise.resolve(false); + } + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "closeAutofillInlineMenu" }, + { frameId: 0 }, + ); + }); + + it("rebuilds the sub frame offsets when the focused field's frame id indicates that it is within a sub frame", async () => { + const focusedFieldData = createFocusedFieldDataMock({ tabId, frameId: middleFrameId }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: middleFrameId }); + }); + + describe("updating the inline menu position", () => { + let sender: chrome.runtime.MessageSender; + + async function flushUpdateInlineMenuPromises() { + await flushOverlayRepositionPromises(); + await flushPromises(); + jest.advanceTimersByTime(250); + await flushPromises(); + } + + beforeEach(async () => { + sender = mock({ tab, frameId: middleFrameId }); + jest.useFakeTimers(); + await initOverlayElementPorts(); + }); + + it("skips updating the position of either inline menu element if a field is not currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: false, + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + + it("sets the inline menu invisible and updates its position", async () => { + overlayBackground["checkIsInlineMenuButtonVisible"] = jest + .fn() + .mockResolvedValue(false); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "toggleAutofillInlineMenuHidden", + styles: { display: "none" }, + }); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + + it("skips updating the inline menu list if the user has the inline menu set to open on button click", async () => { + inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnButtonClick); + tabsSendMessageSpy.mockImplementation((_tab, message, _options) => { + if (message.command === "checkMostRecentlyFocusedFieldHasValue") { + return Promise.resolve(true); + } + + return Promise.resolve({}); + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + + it("skips updating the inline menu list if the focused field has a value and the user status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsSendMessageSpy.mockImplementation((_tab, message, _options) => { + if (message.command === "checkMostRecentlyFocusedFieldHasValue") { + return Promise.resolve(true); + } + + return Promise.resolve({}); + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + }); + }); + + describe("triggerSubFrameFocusInRebuild", () => { + it("triggers a rebuild of the sub frame and updates the inline menu position", async () => { + const rebuildSubFrameOffsetsSpy = jest.spyOn( + overlayBackground as any, + "rebuildSubFrameOffsets", + ); + const repositionInlineMenuSpy = jest.spyOn( + overlayBackground as any, + "repositionInlineMenu", + ); + + sendMockExtensionMessage({ command: "triggerSubFrameFocusInRebuild" }, sender); + await flushOverlayRepositionPromises(); + + expect(rebuildSubFrameOffsetsSpy).toHaveBeenCalled(); + expect(repositionInlineMenuSpy).toHaveBeenCalled(); + }); + }); + + describe("toggleInlineMenuHidden", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("skips adjusting the hidden status of the inline menu if the sender tab does not contain the focused field", async () => { + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + const otherSender = mock({ tab: { id: 2 } }); + + await overlayBackground["toggleInlineMenuHidden"]( + { isInlineMenuHidden: true }, + otherSender, + ); + + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "toggleAutofillInlineMenuHidden", + styles: { display: "none" }, + }); + }); + }); }); }); - describe("updateOverlayCiphers", () => { + describe("updating the overlay ciphers", () => { const url = "https://jest-testing-website.com"; const tab = createChromeTabMock({ url }); const cipher1 = mock({ @@ -160,86 +699,100 @@ describe("OverlayBackground", () => { }); const cipher2 = mock({ id: "id-2", - localData: { lastUsedDate: 111 }, + localData: { lastUsedDate: 222 }, name: "name-2", - type: CipherType.Login, - login: { username: "username-2", uri: url }, + type: CipherType.Card, + card: { subTitle: "subtitle-2" }, }); beforeEach(() => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); }); - it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { + it("skips updating the overlay ciphers if the user's auth status is not unlocked", async () => { activeAccountStatusMock$.next(AuthenticationStatus.Locked); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); - jest.spyOn(cipherService, "getAllDecryptedForUrl"); await overlayBackground.updateOverlayCiphers(); - expect(BrowserApi.getTabFromCurrentWindowId).not.toHaveBeenCalled(); + expect(getTabFromCurrentWindowIdSpy).not.toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); }); - it("ignores updating the overlay ciphers if the tab is undefined", async () => { - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(undefined); - jest.spyOn(cipherService, "getAllDecryptedForUrl"); + it("closes the inline menu on the focused field's tab if the user's auth status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + const previousTab = mock({ id: 1 }); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: 1 }); + getTabSpy.mockResolvedValueOnce(previousTab); await overlayBackground.updateOverlayCiphers(); - expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + previousTab, + { command: "closeAutofillInlineMenu", overlayElement: undefined }, + { frameId: 0 }, + ); + }); + + it("closes the inline menu on the focused field's tab if current tab is different", async () => { + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + const previousTab = mock({ id: 15 }); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: 15 }); + getTabSpy.mockResolvedValueOnce(previousTab); + + await overlayBackground.updateOverlayCiphers(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + previousTab, + { command: "closeAutofillInlineMenu", overlayElement: undefined }, + { frameId: 0 }, + ); }); it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - jest.spyOn(overlayBackground as any, "getOverlayCipherData"); await overlayBackground.updateOverlayCiphers(); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); - expect(overlayBackground["cipherService"].sortCiphersByLastUsedThenName).toHaveBeenCalled(); - expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( + expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ - ["overlay-cipher-0", cipher2], - ["overlay-cipher-1", cipher1], + ["inline-menu-cipher-0", cipher2], + ["inline-menu-cipher-1", cipher1], ]), ); - expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); }); - it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateIsOverlayCiphersPopulated` message to the tab indicating that the list of ciphers is populated", async () => { - overlayBackground["overlayListPort"] = mock(); + it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => { + overlayBackground["inlineMenuListPort"] = mock(); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); - expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayListCiphers", + expect(overlayBackground["inlineMenuListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", ciphers: [ { - card: null, + card: cipher2.card.subTitle, favorite: cipher2.favorite, icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, imageEnabled: true, }, - id: "overlay-cipher-0", - login: { - username: "username-2", - }, + id: "inline-menu-cipher-0", + login: null, name: "name-2", reprompt: cipher2.reprompt, - type: 1, + type: 3, }, { card: null, @@ -250,7 +803,7 @@ describe("OverlayBackground", () => { image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", imageEnabled: true, }, - id: "overlay-cipher-1", + id: "inline-menu-cipher-1", login: { username: "username-1", }, @@ -260,227 +813,822 @@ describe("OverlayBackground", () => { }, ], }); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - tab, - "updateIsOverlayCiphersPopulated", - { isOverlayCiphersPopulated: true }, - ); }); }); - describe("getOverlayCipherData", () => { - const url = "https://jest-testing-website.com"; - const cipher1 = mock({ - id: "id-1", - localData: { lastUsedDate: 222 }, - name: "name-1", - type: CipherType.Login, - login: { username: "username-1", uri: url }, - }); - const cipher2 = mock({ - id: "id-2", - localData: { lastUsedDate: 111 }, - name: "name-2", - type: CipherType.Login, - login: { username: "username-2", uri: url }, - }); - const cipher3 = mock({ - id: "id-3", - localData: { lastUsedDate: 333 }, - name: "name-3", - type: CipherType.Card, - card: { subTitle: "Visa, *6789" }, - }); - const cipher4 = mock({ - id: "id-4", - localData: { lastUsedDate: 444 }, - name: "name-4", - type: CipherType.Card, - card: { subTitle: "Mastercard, *1234" }, - }); + describe("extension message handlers", () => { + describe("autofillOverlayElementClosed message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); - it("formats and returns the cipher data", async () => { - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", cipher2], - ["overlay-cipher-1", cipher1], - ["overlay-cipher-2", cipher3], - ["overlay-cipher-3", cipher4], - ]); + it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { + const port1 = mock(); + const port2 = mock(); + overlayBackground["expiredPorts"] = [port1, port2]; + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - const overlayCipherData = await overlayBackground["getOverlayCipherData"](); - - expect(overlayCipherData).toStrictEqual([ - { - card: null, - favorite: cipher2.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, + sendMockExtensionMessage( + { + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, }, - id: "overlay-cipher-0", - login: { - username: "username-2", - }, - name: "name-2", - reprompt: cipher2.reprompt, - type: 1, - }, - { - card: null, - favorite: cipher1.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, - }, - id: "overlay-cipher-1", - login: { - username: "username-1", - }, - name: "name-1", - reprompt: cipher1.reprompt, - type: 1, - }, - { - card: "Visa, *6789", - favorite: cipher3.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: undefined, - imageEnabled: true, - }, - id: "overlay-cipher-2", - login: null, - name: "name-3", - reprompt: cipher3.reprompt, - type: 3, - }, - { - card: "Mastercard, *1234", - favorite: cipher4.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: undefined, - imageEnabled: true, - }, - id: "overlay-cipher-3", - login: null, - name: "name-4", - reprompt: cipher4.reprompt, - type: 3, - }, - ]); - }); - }); + sender, + ); - describe("getAuthStatus", () => { - it("will update the user's auth status but will not update the overlay ciphers", async () => { - const authStatus = AuthenticationStatus.Unlocked; - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); - jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + expect(port1.disconnect).toHaveBeenCalled(); + expect(port2.disconnect).toHaveBeenCalled(); + }); - const status = await overlayBackground["getAuthStatus"](); + it("disconnects the button element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }); - expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayButtonAuthStatus"]).not.toHaveBeenCalled(); - expect(overlayBackground["updateOverlayCiphers"]).not.toHaveBeenCalled(); - expect(overlayBackground["userAuthStatus"]).toBe(authStatus); - expect(status).toBe(authStatus); - }); + expect(buttonPortSpy.disconnect).toHaveBeenCalled(); + }); - it("will update the user's auth status and update the overlay ciphers if the status has been modified", async () => { - const authStatus = AuthenticationStatus.Unlocked; - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); - jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + it("disconnects the list element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.List, + }); - await overlayBackground["getAuthStatus"](); - - expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayButtonAuthStatus"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); - expect(overlayBackground["userAuthStatus"]).toBe(authStatus); - }); - }); - - describe("updateOverlayButtonAuthStatus", () => { - it("will send a message to the button port with the user's auth status", () => { - overlayBackground["overlayButtonPort"] = mock(); - jest.spyOn(overlayBackground["overlayButtonPort"], "postMessage"); - - overlayBackground["updateOverlayButtonAuthStatus"](); - - expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayButtonAuthStatus", - authStatus: overlayBackground["userAuthStatus"], + expect(listPortSpy.disconnect).toHaveBeenCalled(); }); }); - }); - describe("getTranslations", () => { - it("will query the overlay page translations if they have not been queried", () => { - overlayBackground["overlayPageTranslations"] = undefined; - jest.spyOn(overlayBackground as any, "getTranslations"); - jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key); - jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en"); + describe("autofillOverlayAddNewVaultItem message handler", () => { + let sender: chrome.runtime.MessageSender; + let openAddEditVaultItemPopoutSpy: jest.SpyInstance; - const translations = overlayBackground["getTranslations"](); + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + openAddEditVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openAddEditVaultItemPopout") + .mockImplementation(); + }); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - const translationKeys = [ - "opensInANewWindow", - "bitwardenOverlayButton", - "toggleBitwardenVaultOverlay", - "bitwardenVault", - "unlockYourAccountToViewMatchingLogins", - "unlockAccount", - "fillCredentialsFor", - "partialUsername", - "view", - "noItemsToShow", - "newItem", - "addNewVaultItem", + it("will not open the add edit popout window if the message does not have a login cipher provided", () => { + sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); + + expect(cipherService.setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(openAddEditVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the add edit popout window after creating a new cipher", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); + expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); + }); + }); + + describe("checkIsInlineMenuCiphersPopulated message handler", () => { + let focusedFieldData: FocusedFieldData; + + beforeEach(() => { + focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + mock({ tab: { id: 2 }, frameId: 0 }), + ); + }); + + it("returns false if the sender's tab id is not equal to the focused field's tab id", async () => { + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsInlineMenuCiphersPopulated" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(false); + }); + + it("returns false if the overlay login cipher are not populated", () => {}); + + it("returns true if the overlay login ciphers are populated", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-0", mock()], + ]); + + sendMockExtensionMessage( + { command: "checkIsInlineMenuCiphersPopulated" }, + mock({ tab: { id: 2 } }), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("updateFocusedFieldData message handler", () => { + it("sends a message to the sender frame to unset the most recently focused field data when the currently focused field does not belong to the sender", async () => { + const tab = createChromeTabMock({ id: 2 }); + const firstSender = mock({ tab, frameId: 100 }); + const focusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: firstSender.frameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + firstSender, + ); + await flushPromises(); + + const secondSender = mock({ tab, frameId: 10 }); + const otherFocusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: secondSender.frameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData: otherFocusedFieldData }, + secondSender, + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "unsetMostRecentlyFocusedField" }, + { frameId: firstSender.frameId }, + ); + }); + }); + + describe("checkIsFieldCurrentlyFocused message handler", () => { + it("returns true when a form field is currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + + sendMockExtensionMessage( + { command: "checkIsFieldCurrentlyFocused" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("checkIsFieldCurrentlyFilling message handler", () => { + it("returns true if autofill is currently running", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: true, + }); + + sendMockExtensionMessage( + { command: "checkIsFieldCurrentlyFilling" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("getAutofillInlineMenuVisibility message handler", () => { + it("returns the current inline menu visibility setting", async () => { + sendMockExtensionMessage( + { command: "getAutofillInlineMenuVisibility" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); + }); + }); + + describe("openAutofillInlineMenu message handler", () => { + let sender: chrome.runtime.MessageSender; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + getTabFromCurrentWindowIdSpy.mockResolvedValue(sender.tab); + tabsSendMessageSpy.mockImplementation(); + }); + + it("opens the autofill inline menu by sending a message to the current tab", async () => { + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullInlineMenu: false, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 0 }, + ); + }); + + it("sends the open menu message to the focused field's frameId", async () => { + sender.frameId = 10; + sendMockExtensionMessage({ command: "updateFocusedFieldData" }, sender); + await flushPromises(); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullInlineMenu: false, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 10 }, + ); + }); + }); + + describe("closeAutofillInlineMenu", () => { + let sender: chrome.runtime.MessageSender; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: false, + }); + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: false, + }); + }); + + it("sends a message to close the inline menu without checking field focus state if forcing the closure", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + await flushPromises(); + + sendMockExtensionMessage( + { + command: "closeAutofillInlineMenu", + forceCloseInlineMenu: true, + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + }); + + it("skips sending a message to close the inline menu if a form field is currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + await flushPromises(); + + sendMockExtensionMessage( + { + command: "closeAutofillInlineMenu", + forceCloseInlineMenu: false, + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + await flushPromises(); + + expect(tabsSendMessageSpy).not.toHaveBeenCalled(); + }); + + it("sends a message to close the inline menu list only if the field is currently filling", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: true, + }); + await flushPromises(); + + sendMockExtensionMessage({ command: "closeAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + }); + + it("sends a message to close the inline menu if the form field is not focused and not filling", async () => { + overlayBackground["isInlineMenuButtonVisible"] = true; + overlayBackground["isInlineMenuListVisible"] = true; + + sendMockExtensionMessage({ command: "closeAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: undefined, + }, + { frameId: 0 }, + ); + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(false); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + + it("sets a property indicating that the inline menu button is not visible", async () => { + overlayBackground["isInlineMenuButtonVisible"] = true; + + sendMockExtensionMessage( + { command: "closeAutofillInlineMenu", overlayElement: AutofillOverlayElement.Button }, + sender, + ); + await flushPromises(); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(false); + }); + + it("sets a property indicating that the inline menu list is not visible", async () => { + overlayBackground["isInlineMenuListVisible"] = true; + + sendMockExtensionMessage( + { command: "closeAutofillInlineMenu", overlayElement: AutofillOverlayElement.List }, + sender, + ); + await flushPromises(); + + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + }); + + describe("checkAutofillInlineMenuFocused message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("skips checking if the inline menu is focused if the sender does not contain the focused field", async () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ command: "checkAutofillInlineMenuFocused" }, sender); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + + it("will check if the inline menu list is focused if the list port is open", () => { + sendMockExtensionMessage({ command: "checkAutofillInlineMenuFocused" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + + it("will check if the overlay button is focused if the list port is not open", () => { + overlayBackground["inlineMenuListPort"] = undefined; + + sendMockExtensionMessage({ command: "checkAutofillInlineMenuFocused" }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + }); + }); + + describe("focusAutofillInlineMenuList message handler", () => { + it("will send a `focusInlineMenuList` message to the overlay list port", async () => { + await initOverlayElementPorts({ initList: true, initButton: false }); + + sendMockExtensionMessage({ command: "focusAutofillInlineMenuList" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "focusAutofillInlineMenuList", + }); + }); + }); + + describe("updateAutofillInlineMenuPosition message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("ignores updating the position if the overlay element type is not provided", () => { + sendMockExtensionMessage({ command: "updateAutofillInlineMenuPosition" }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("skips updating the position if the most recently focused field is different than the message sender", () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ command: "updateAutofillInlineMenuPosition" }, sender); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("updates the inline menu button's position", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, + }); + }); + + it("modifies the inline menu button's height for medium sized input elements", async () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, + }); + }); + + it("modifies the inline menu button's height for large sized input elements", async () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, + }); + }); + + it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", async () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, + }); + }); + + it("updates the inline menu list's position", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.List, + }); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { left: "2px", top: "4px", width: "4px" }, + }); + }); + + it("sends a message that triggers a simultaneous fade in for both inline menu elements", async () => { + jest.useFakeTimers(); + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.List, + }); + await flushPromises(); + jest.advanceTimersByTime(150); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "fadeInAutofillInlineMenuIframe", + }); + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "fadeInAutofillInlineMenuIframe", + }); + }); + + it("triggers a debounced reposition of the inline menu if the sender frame has a `null` sub frame offsets value", async () => { + jest.useFakeTimers(); + const focusedFieldData = createFocusedFieldDataMock(); + const sender = mock({ + tab: { id: focusedFieldData.tabId }, + frameId: focusedFieldData.frameId, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + overlayBackground["subFrameOffsetsForTab"][focusedFieldData.tabId] = new Map([ + [focusedFieldData.frameId, null], + ]); + tabsSendMessageSpy.mockImplementation(); + jest.spyOn(overlayBackground as any, "updateInlineMenuPositionAfterRepositionEvent"); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.List, + }, + sender, + ); + await flushPromises(); + jest.advanceTimersByTime(150); + + expect( + overlayBackground["updateInlineMenuPositionAfterRepositionEvent"], + ).toHaveBeenCalled(); + }); + }); + + describe("updateAutofillInlineMenuElementIsVisibleStatus message handler", () => { + let sender: chrome.runtime.MessageSender; + let focusedFieldData: FocusedFieldData; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + focusedFieldData = createFocusedFieldDataMock(); + overlayBackground["isInlineMenuButtonVisible"] = true; + overlayBackground["isInlineMenuListVisible"] = false; + }); + + it("skips updating the inline menu visibility status if the sender tab does not contain the focused field", async () => { + const otherSender = mock({ tab: { id: 2 } }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherSender, + ); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuElementIsVisibleStatus", + overlayElement: AutofillOverlayElement.Button, + isVisible: false, + }, + sender, + ); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(true); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + + it("updates the visibility status of the inline menu button", async () => { + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuElementIsVisibleStatus", + overlayElement: AutofillOverlayElement.Button, + isVisible: false, + }, + sender, + ); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(false); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + + it("updates the visibility status of the inline menu list", async () => { + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuElementIsVisibleStatus", + overlayElement: AutofillOverlayElement.List, + isVisible: true, + }, + sender, + ); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(true); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(true); + }); + }); + + describe("checkIsAutofillInlineMenuButtonVisible message handler", () => { + it("returns true when the inline menu button is visible", async () => { + overlayBackground["isInlineMenuButtonVisible"] = true; + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsAutofillInlineMenuButtonVisible" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("checkIsAutofillInlineMenuListVisible message handler", () => { + it("returns true when the inline menu list is visible", async () => { + overlayBackground["isInlineMenuListVisible"] = true; + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsAutofillInlineMenuListVisible" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("getCurrentTabFrameId message handler", () => { + it("returns the sender's frame id", async () => { + const sender = mock({ frameId: 1 }); + + sendMockExtensionMessage({ command: "getCurrentTabFrameId" }, sender, sendResponse); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(1); + }); + }); + + describe("destroyAutofillInlineMenuListeners", () => { + it("sends a message to the passed frameId that triggers a destruction of the inline menu listeners on that frame", () => { + const sender = mock({ tab: { id: 1 }, frameId: 0 }); + + sendMockExtensionMessage( + { command: "destroyAutofillInlineMenuListeners", subFrameData: { frameId: 10 } }, + sender, + ); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "destroyAutofillInlineMenuListeners" }, + { frameId: 10 }, + ); + }); + }); + + describe("unlockCompleted", () => { + let updateInlineMenuCiphersSpy: jest.SpyInstance; + + beforeEach(async () => { + updateInlineMenuCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers"); + await initOverlayElementPorts(); + }); + + it("updates the inline menu button auth status", async () => { + sendMockExtensionMessage({ command: "unlockCompleted" }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateInlineMenuButtonAuthStatus", + authStatus: AuthenticationStatus.Unlocked, + }); + }); + + it("updates the overlay ciphers", async () => { + const updateInlineMenuCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers"); + sendMockExtensionMessage({ command: "unlockCompleted" }); + await flushPromises(); + + expect(updateInlineMenuCiphersSpy).toHaveBeenCalled(); + }); + + it("opens the inline menu if a retry command is present in the message", async () => { + updateInlineMenuCiphersSpy.mockImplementation(); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(createChromeTabMock({ id: 1 })); + sendMockExtensionMessage({ + command: "unlockCompleted", + data: { + commandToRetry: { message: { command: "openAutofillInlineMenu" } }, + }, + }); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + expect.any(Object), + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: true, + isOpeningFullInlineMenu: false, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 0 }, + ); + }); + }); + + describe("extension messages that trigger an update of the inline menu ciphers", () => { + const extensionMessages = [ + "doFullSync", + "addedCipher", + "addEditCipherSubmitted", + "editedCipher", + "deletedCipher", ]; - translationKeys.forEach((key) => { - expect(overlayBackground["i18nService"].translate).toHaveBeenCalledWith(key); + + beforeEach(() => { + jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); }); - expect(translations).toStrictEqual({ - locale: "en", - opensInANewWindow: "opensInANewWindow", - buttonPageTitle: "bitwardenOverlayButton", - toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", - listPageTitle: "bitwardenVault", - unlockYourAccount: "unlockYourAccountToViewMatchingLogins", - unlockAccount: "unlockAccount", - fillCredentialsFor: "fillCredentialsFor", - partialUsername: "partialUsername", - view: "view", - noItemsToShow: "noItemsToShow", - newItem: "newItem", - addNewVaultItem: "addNewVaultItem", + + extensionMessages.forEach((message) => { + it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { + sendMockExtensionMessage({ command: message }); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); }); }); }); - describe("setupExtensionMessageListeners", () => { - it("will set up onMessage and onConnect listeners", () => { - overlayBackground["setupExtensionMessageListeners"](); - - // eslint-disable-next-line - expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); - expect(chrome.runtime.onConnect.addListener).toHaveBeenCalled(); - }); - }); - - describe("handleExtensionMessage", () => { + describe("handle extension onMessage", () => { it("will return early if the message command is not present within the extensionMessageHandlers", () => { const message = { command: "not-a-command", @@ -494,970 +1642,591 @@ describe("OverlayBackground", () => { sendResponse, ); - expect(returnValue).toBe(undefined); + expect(returnValue).toBe(null); expect(sendResponse).not.toHaveBeenCalled(); }); + }); - it("will trigger the message handler and return undefined if the message does not have a response", () => { - const message = { - command: "autofillOverlayElementClosed", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - jest.spyOn(overlayBackground as any, "overlayElementClosed"); + describe("inline menu button message handlers", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuButtonPort"; - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); - - expect(returnValue).toBe(undefined); - expect(sendResponse).not.toHaveBeenCalled(); - expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message, sender); + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + buttonMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); }); - it("will return a response if the message handler returns a response", async () => { - const message = { - command: "openAutofillOverlay", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - jest.spyOn(overlayBackground as any, "getTranslations").mockReturnValue("translations"); + describe("autofillInlineMenuButtonClicked message handler", () => { + it("opens the unlock vault popout if the user auth status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsSendMessageSpy.mockImplementation(); - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); + sendPortMessage(buttonMessageConnectorSpy, { + command: "autofillInlineMenuButtonClicked", + portKey, + }); + await flushPromises(); - expect(returnValue).toBe(true); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "closeAutofillInlineMenu", overlayElement: undefined }, + { frameId: 0 }, + ); + expect(tabSendMessageDataSpy).toBeCalledWith( + sender.tab, + "addToLockedVaultPendingNotifications", + { + commandToRetry: { message: { command: "openAutofillInlineMenu" }, sender }, + target: "overlay.background", + }, + ); + expect(openUnlockPopoutSpy).toHaveBeenCalled(); + }); + + it("opens the inline menu if the user auth status is unlocked", async () => { + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(sender.tab); + sendPortMessage(buttonMessageConnectorSpy, { + command: "autofillInlineMenuButtonClicked", + portKey, + }); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullInlineMenu: true, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 0 }, + ); + }); }); - describe("extension message handlers", () => { - beforeEach(() => { - jest - .spyOn(overlayBackground as any, "getAuthStatus") - .mockResolvedValue(AuthenticationStatus.Unlocked); + describe("triggerDelayedAutofillInlineMenuClosure message handler", () => { + it("skips triggering the delayed closure of the inline menu if a field is currently focused", async () => { + jest.useFakeTimers(); + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + await flushPromises(); + + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, + }); + await flushPromises(); + jest.advanceTimersByTime(100); + + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); }); - describe("openAutofillOverlay message handler", () => { - it("opens the autofill overlay by sending a message to the current tab", async () => { - const sender = mock({ tab: { id: 1 } }); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - sendMockExtensionMessage({ command: "openAutofillOverlay" }); - await flushPromises(); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - sender.tab, - "openAutofillOverlay", - { - isFocusingFieldElement: false, - isOpeningFullOverlay: false, - authStatus: AuthenticationStatus.Unlocked, - }, - ); + it("sends a message to the button and list ports that triggers a delayed closure of the inline menu", async () => { + jest.useFakeTimers(); + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, }); + await flushPromises(); + jest.advanceTimersByTime(100); + + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).toHaveBeenCalledWith(message); }); - describe("autofillOverlayElementClosed message handler", () => { - beforeEach(async () => { - await initOverlayElementPorts(); + it("triggers a single delayed closure if called again within a 100ms threshold", async () => { + jest.useFakeTimers(); + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, }); - - it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { - const port1 = mock(); - const port2 = mock(); - overlayBackground["expiredPorts"] = [port1, port2]; - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage( - { - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.Button, - }, - sender, - ); - - expect(port1.disconnect).toHaveBeenCalled(); - expect(port2.disconnect).toHaveBeenCalled(); + await flushPromises(); + jest.advanceTimersByTime(50); + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, }); + await flushPromises(); + jest.advanceTimersByTime(100); - it("disconnects the button element port", () => { - sendMockExtensionMessage({ - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.Button, - }); + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + expect(buttonPortSpy.postMessage).toHaveBeenCalledTimes(2); + expect(buttonPortSpy.postMessage).not.toHaveBeenNthCalledWith(1, message); + expect(buttonPortSpy.postMessage).toHaveBeenNthCalledWith(2, message); + expect(listPortSpy.postMessage).toHaveBeenCalledTimes(2); + expect(listPortSpy.postMessage).not.toHaveBeenNthCalledWith(1, message); + expect(listPortSpy.postMessage).toHaveBeenNthCalledWith(2, message); + }); + }); - expect(buttonPortSpy.disconnect).toHaveBeenCalled(); - expect(overlayBackground["overlayButtonPort"]).toBeNull(); + describe("autofillInlineMenuBlurred message handler", () => { + it("sends a message to the inline menu list to check if the element is focused", async () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + portKey, }); + await flushPromises(); - it("disconnects the list element port", () => { - sendMockExtensionMessage({ - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.List, - }); - - expect(listPortSpy.disconnect).toHaveBeenCalled(); - expect(overlayBackground["overlayListPort"]).toBeNull(); + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", }); }); + }); - describe("autofillOverlayAddNewVaultItem message handler", () => { - let sender: chrome.runtime.MessageSender; - beforeEach(() => { - sender = mock({ tab: { id: 1 } }); - jest - .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") - .mockImplementation(); - jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); + describe("redirectAutofillInlineMenuFocusOut message handler", () => { + it("ignores the redirect message if the direction is not provided", () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + portKey, }); - it("will not open the add edit popout window if the message does not have a login cipher provided", () => { - sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); - - expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); - expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); - }); - - it("will open the add edit popout window after creating a new cipher", async () => { - jest.spyOn(BrowserApi, "sendMessage"); - - sendMockExtensionMessage( - { - command: "autofillOverlayAddNewVaultItem", - login: { - uri: "https://tacos.com", - hostname: "", - username: "username", - password: "password", - }, - }, - sender, - ); - await flushPromises(); - - expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); - expect(BrowserApi.sendMessage).toHaveBeenCalledWith( - "inlineAutofillMenuRefreshAddEditCipher", - ); - expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); - }); + expect(tabSendMessageDataSpy).not.toHaveBeenCalled(); }); - describe("getAutofillOverlayVisibility message handler", () => { - beforeEach(() => { - jest - .spyOn(overlayBackground as any, "getOverlayVisibility") - .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + it("sends the redirect message if the direction is provided", () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + direction: RedirectFocusDirection.Next, + portKey, }); - it("will set the overlayVisibility property", async () => { - sendMockExtensionMessage({ command: "getAutofillOverlayVisibility" }); - await flushPromises(); - - expect(await overlayBackground["getOverlayVisibility"]()).toBe( - AutofillOverlayVisibility.OnFieldFocus, - ); - }); - - it("returns the overlayVisibility property", async () => { - const sendMessageSpy = jest.fn(); - - sendMockExtensionMessage( - { command: "getAutofillOverlayVisibility" }, - undefined, - sendMessageSpy, - ); - await flushPromises(); - - expect(sendMessageSpy).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); - }); + expect(tabSendMessageDataSpy).toHaveBeenCalledWith( + sender.tab, + "redirectAutofillInlineMenuFocusOut", + { direction: RedirectFocusDirection.Next }, + ); }); + }); - describe("checkAutofillOverlayFocused message handler", () => { - beforeEach(async () => { - await initOverlayElementPorts(); + describe("updateAutofillInlineMenuColorScheme message handler", () => { + it("sends a message to the button port to update the inline menu color scheme", async () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "updateAutofillInlineMenuColorScheme", + portKey, }); + await flushPromises(); - it("will check if the overlay list is focused if the list port is open", () => { - sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ - command: "checkAutofillOverlayListFocused", - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "checkAutofillOverlayButtonFocused", - }); - }); - - it("will check if the overlay button is focused if the list port is not open", () => { - overlayBackground["overlayListPort"] = undefined; - - sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "checkAutofillOverlayButtonFocused", - }); - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "checkAutofillOverlayListFocused", - }); - }); - }); - - describe("focusAutofillOverlayList message handler", () => { - it("will send a `focusOverlayList` message to the overlay list port", async () => { - await initOverlayElementPorts({ initList: true, initButton: false }); - - sendMockExtensionMessage({ command: "focusAutofillOverlayList" }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "focusOverlayList" }); - }); - }); - - describe("updateAutofillOverlayPosition message handler", () => { - beforeEach(async () => { - await overlayBackground["handlePortOnConnect"]( - createPortSpyMock(AutofillOverlayPort.List), - ); - listPortSpy = overlayBackground["overlayListPort"]; - - await overlayBackground["handlePortOnConnect"]( - createPortSpyMock(AutofillOverlayPort.Button), - ); - buttonPortSpy = overlayBackground["overlayButtonPort"]; - }); - - it("ignores updating the position if the overlay element type is not provided", () => { - sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }); - - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - }); - - it("skips updating the position if the most recently focused field is different than the message sender", () => { - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }, sender); - - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - }); - - it("updates the overlay button's position", () => { - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, - }); - }); - - it("modifies the overlay button's height for medium sized input elements", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, - }); - }); - - it("modifies the overlay button's height for large sized input elements", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, - }); - }); - - it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, - }); - }); - - it("will post a message to the overlay list facilitating an update of the list's position", () => { - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - overlayBackground["updateOverlayPosition"]( - { overlayElement: AutofillOverlayElement.List }, - sender, - ); - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.List, - }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { left: "2px", top: "4px", width: "4px" }, - }); - }); - }); - - describe("updateOverlayHidden", () => { - beforeEach(async () => { - await initOverlayElementPorts(); - }); - - it("returns early if the display value is not provided", () => { - const message = { - command: "updateAutofillOverlayHidden", - }; - - sendMockExtensionMessage(message); - - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); - }); - - it("posts a message to the overlay button and list with the display value", () => { - const message = { command: "updateAutofillOverlayHidden", display: "none" }; - - sendMockExtensionMessage(message); - - expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayHidden", - styles: { - display: message.display, - }, - }); - expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayHidden", - styles: { - display: message.display, - }, - }); - }); - }); - - describe("collectPageDetailsResponse message handler", () => { - let sender: chrome.runtime.MessageSender; - const pageDetails1 = createAutofillPageDetailsMock({ - login: { username: "username1", password: "password1" }, - }); - const pageDetails2 = createAutofillPageDetailsMock({ - login: { username: "username2", password: "password2" }, - }); - - beforeEach(() => { - sender = mock({ tab: { id: 1 } }); - }); - - it("stores the page details provided by the message by the tab id of the sender", () => { - sendMockExtensionMessage( - { command: "collectPageDetailsResponse", details: pageDetails1 }, - sender, - ); - - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( - new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - ]), - ); - }); - - it("updates the page details for a tab that already has a set of page details stored ", () => { - const secondFrameSender = mock({ - tab: { id: 1 }, - frameId: 3, - }); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - ]); - - sendMockExtensionMessage( - { command: "collectPageDetailsResponse", details: pageDetails2 }, - secondFrameSender, - ); - - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( - new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - [ - secondFrameSender.frameId, - { - frameId: secondFrameSender.frameId, - tab: secondFrameSender.tab, - details: pageDetails2, - }, - ], - ]), - ); - }); - }); - - describe("unlockCompleted message handler", () => { - let getAuthStatusSpy: jest.SpyInstance; - - beforeEach(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(BrowserApi, "tabSendMessageData"); - getAuthStatusSpy = jest - .spyOn(overlayBackground as any, "getAuthStatus") - .mockImplementation(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - return Promise.resolve(AuthenticationStatus.Unlocked); - }); - }); - - it("updates the user's auth status but does not open the overlay", async () => { - const message = { - command: "unlockCompleted", - data: { - commandToRetry: { message: { command: "" } }, - }, - }; - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(getAuthStatusSpy).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); - }); - - it("updates user's auth status and opens the overlay if a follow up command is provided", async () => { - const sender = mock({ tab: { id: 1 } }); - const message = { - command: "unlockCompleted", - data: { - commandToRetry: { message: { command: "openAutofillOverlay" } }, - }, - }; - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(getAuthStatusSpy).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - sender.tab, - "openAutofillOverlay", - { - isFocusingFieldElement: true, - isOpeningFullOverlay: false, - authStatus: AuthenticationStatus.Unlocked, - }, - ); - }); - }); - - describe("extension messages that trigger an update of the inline menu ciphers", () => { - const extensionMessages = [ - "addedCipher", - "addEditCipherSubmitted", - "editedCipher", - "deletedCipher", - ]; - - beforeEach(() => { - jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); - }); - - extensionMessages.forEach((message) => { - it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { - sendMockExtensionMessage({ command: message }); - expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); - }); + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuColorScheme", }); }); }); }); - describe("handlePortOnConnect", () => { - beforeEach(() => { - jest.spyOn(overlayBackground as any, "updateOverlayPosition").mockImplementation(); - jest.spyOn(overlayBackground as any, "getAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "getTranslations").mockImplementation(); - jest.spyOn(overlayBackground as any, "getOverlayCipherData").mockImplementation(); + describe("inline menu list message handlers", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuListPort"; + + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + listMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); }); + describe("checkAutofillInlineMenuButtonFocused message handler", () => { + it("sends a message to the inline menu button to check if the element is focused", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "checkAutofillInlineMenuButtonFocused", + portKey, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + }); + + describe("autofillInlineMenuBlurred message handler", () => { + it("sends a message to the inline menu button to check if the element is focused", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + portKey, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + }); + + describe("unlockVault message handler", () => { + it("opens the unlock vault popout", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsSendMessageSpy.mockImplementation(); + + sendPortMessage(listMessageConnectorSpy, { command: "unlockVault", portKey }); + await flushPromises(); + + expect(openUnlockPopoutSpy).toHaveBeenCalled(); + }); + }); + + describe("fillAutofillInlineMenuCipher message handler", () => { + const pageDetails = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + + it("ignores the fill request if the overlay cipher id is not provided", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if the tab does not contain any identified page details", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if a master password reprompt is required", async () => { + const cipher = mock({ + reprompt: CipherRepromptType.Password, + type: CipherType.Login, + }); + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(true); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalledWith(cipher, sender.tab); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { + const cipher1 = mock({ id: "inline-menu-cipher-1" }); + const cipher2 = mock({ id: "inline-menu-cipher-2" }); + const cipher3 = mock({ id: "inline-menu-cipher-3" }); + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-1", cipher1], + ["inline-menu-cipher-2", cipher2], + ["inline-menu-cipher-3", cipher3], + ]); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-2", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalledWith( + cipher2, + sender.tab, + ); + expect(autofillService.doAutoFill).toHaveBeenCalledWith({ + tab: sender.tab, + cipher: cipher2, + pageDetails: [pageDetailsForTab], + fillNewPassword: true, + allowTotpAutofill: true, + }); + expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual( + new Map([ + ["inline-menu-cipher-2", cipher2], + ["inline-menu-cipher-1", cipher1], + ["inline-menu-cipher-3", cipher3], + ]).entries(), + ); + }); + + it("copies the cipher's totp code to the clipboard after filling", async () => { + const cipher1 = mock({ id: "inline-menu-cipher-1" }); + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); + const copyToClipboardSpy = jest + .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") + .mockImplementation(); + autofillService.doAutoFill.mockResolvedValue("totp-code"); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-2", + portKey, + }); + await flushPromises(); + + expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); + }); + }); + + describe("addNewVaultItem message handler", () => { + it("skips sending the `addNewVaultItemFromOverlay` message if the sender tab does not contain the focused field", async () => { + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + await flushPromises(); + + sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey }); + await flushPromises(); + + expect(tabsSendMessageSpy).not.toHaveBeenCalled(); + }); + + it("sends a message to the tab to add a new vault item", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + await flushPromises(); + + sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey }); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "addNewVaultItemFromOverlay" }, + { frameId: sender.frameId }, + ); + }); + }); + + describe("viewSelectedCipher message handler", () => { + let openViewVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + openViewVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openViewVaultItemPopout") + .mockImplementation(); + }); + + it("returns early if the passed cipher ID does not match one of the inline menu ciphers", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-0", mock({ id: "inline-menu-cipher-0" })], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "viewSelectedCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the view vault item popout with the selected cipher", async () => { + const cipher = mock({ id: "inline-menu-cipher-1" }); + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-0", mock({ id: "inline-menu-cipher-0" })], + ["inline-menu-cipher-1", cipher], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "viewSelectedCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).toHaveBeenCalledWith(sender.tab, { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }); + }); + }); + + describe("redirectAutofillInlineMenuFocusOut message handler", () => { + it("redirects focus out of the inline menu list", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + direction: RedirectFocusDirection.Next, + portKey, + }); + await flushPromises(); + + expect(tabSendMessageDataSpy).toHaveBeenCalledWith( + sender.tab, + "redirectAutofillInlineMenuFocusOut", + { direction: RedirectFocusDirection.Next }, + ); + }); + }); + + describe("updateAutofillInlineMenuListHeight message handler", () => { + it("sends a message to the list port to update the menu iframe position", () => { + sendPortMessage(listMessageConnectorSpy, { + command: "updateAutofillInlineMenuListHeight", + styles: { height: "100px" }, + portKey, + }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "100px" }, + }); + }); + }); + }); + + describe("handle web navigation on committed events", () => { + describe("navigation event occurs in the top frame of the tab", () => { + it("removes the collected page details", async () => { + const sender = mock({ + tabId: 1, + frameId: 0, + }); + overlayBackground["pageDetailsForTab"][sender.tabId] = new Map([ + [sender.frameId, createPageDetailMock()], + ]); + + triggerWebNavigationOnCommittedEvent(sender); + await flushPromises(); + + expect(overlayBackground["pageDetailsForTab"][sender.tabId]).toBe(undefined); + }); + + it("clears the sub frames associated with the tab", () => { + const sender = mock({ + tabId: 1, + frameId: 0, + }); + const subFrameId = 10; + overlayBackground["subFrameOffsetsForTab"][sender.tabId] = new Map([ + [subFrameId, mock()], + ]); + + triggerWebNavigationOnCommittedEvent(sender); + + expect(overlayBackground["subFrameOffsetsForTab"][sender.tabId]).toBe(undefined); + }); + }); + + describe("navigation event occurs within sub frame", () => { + it("clears the sub frame offsets for the current frame", () => { + const sender = mock({ + tabId: 1, + frameId: 1, + }); + overlayBackground["subFrameOffsetsForTab"][sender.tabId] = new Map([ + [sender.frameId, mock()], + ]); + + triggerWebNavigationOnCommittedEvent(sender); + + expect(overlayBackground["subFrameOffsetsForTab"][sender.tabId].get(sender.frameId)).toBe( + undefined, + ); + }); + }); + }); + + describe("handle port onConnect", () => { it("skips setting up the overlay port if the port connection is not for an overlay element", async () => { const port = createPortSpyMock("not-an-overlay-element"); - await overlayBackground["handlePortOnConnect"](port); + triggerPortOnConnectEvent(port); + await flushPromises(); expect(port.onMessage.addListener).not.toHaveBeenCalled(); expect(port.postMessage).not.toHaveBeenCalled(); }); - it("sets up the overlay list port if the port connection is for the overlay list", async () => { - await initOverlayElementPorts({ initList: true, initButton: false }); + it("generates a random 12 character string used to validate port messages from the tab", async () => { + const port = createPortSpyMock(AutofillOverlayPort.Button); + overlayBackground["inlineMenuButtonPort"] = port; + + triggerPortOnConnectEvent(port); await flushPromises(); - expect(overlayBackground["overlayButtonPort"]).toBeUndefined(); - expect(listPortSpy.onMessage.addListener).toHaveBeenCalled(); - expect(listPortSpy.postMessage).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css"); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( - { overlayElement: AutofillOverlayElement.List }, - listPortSpy.sender, - ); - }); - - it("sets up the overlay button port if the port connection is for the overlay button", async () => { - await initOverlayElementPorts({ initList: false, initButton: true }); - await flushPromises(); - - expect(overlayBackground["overlayListPort"]).toBeUndefined(); - expect(buttonPortSpy.onMessage.addListener).toHaveBeenCalled(); - expect(buttonPortSpy.postMessage).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css"); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( - { overlayElement: AutofillOverlayElement.Button }, - buttonPortSpy.sender, - ); + expect(portKeyForTabSpy[port.sender.tab.id]).toHaveLength(12); }); it("stores an existing overlay port so that it can be disconnected at a later time", async () => { - overlayBackground["overlayButtonPort"] = mock(); + overlayBackground["inlineMenuButtonPort"] = mock(); await initOverlayElementPorts({ initList: false, initButton: true }); await flushPromises(); expect(overlayBackground["expiredPorts"].length).toBe(1); }); + }); - it("gets the system theme", async () => { - themeStateService.selectedTheme$ = of(ThemeType.System); + describe("handle overlay element port onMessage", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuListPort"; - await initOverlayElementPorts({ initList: true, initButton: false }); + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + listMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); + }); + + it("ignores messages that do not contain a valid portKey", async () => { + triggerPortOnMessageEvent(buttonMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + }); await flushPromises(); - expect(listPortSpy.postMessage).toHaveBeenCalledWith( - expect.objectContaining({ theme: ThemeType.System }), - ); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + }); + + it("ignores messages from ports that are not listened to", () => { + triggerPortOnMessageEvent(buttonPortSpy, { + command: "autofillInlineMenuBlurred", + portKey, + }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); }); }); - describe("handleOverlayElementPortMessage", () => { - beforeEach(async () => { + describe("handle port onDisconnect", () => { + it("sets the disconnected port to a `null` value", async () => { await initOverlayElementPorts(); - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - }); - it("ignores port messages that do not contain a handler", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + triggerPortOnDisconnectEvent(buttonPortSpy); + triggerPortOnDisconnectEvent(listPortSpy); + await flushPromises(); - sendPortMessage(buttonPortSpy, { command: "checkAutofillOverlayButtonFocused" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).not.toHaveBeenCalled(); - }); - - describe("overlay button message handlers", () => { - it("unlocks the vault if the user auth status is not unlocked", () => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(overlayBackground as any, "unlockVault").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); - - expect(overlayBackground["unlockVault"]).toHaveBeenCalled(); - }); - - it("opens the autofill overlay if the auth status is unlocked", () => { - jest.spyOn(overlayBackground as any, "openOverlay").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); - - expect(overlayBackground["openOverlay"]).toHaveBeenCalled(); - }); - - describe("closeAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(buttonPortSpy, { command: "closeAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: false }, - ); - }); - }); - - describe("forceCloseAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(buttonPortSpy, { command: "forceCloseAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: true }, - ); - }); - }); - - describe("overlayPageBlurred", () => { - it("checks if the overlay list is focused", () => { - jest.spyOn(overlayBackground as any, "checkOverlayListFocused"); - - sendPortMessage(buttonPortSpy, { command: "overlayPageBlurred" }); - - expect(overlayBackground["checkOverlayListFocused"]).toHaveBeenCalled(); - }); - }); - - describe("redirectOverlayFocusOut", () => { - beforeEach(() => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - }); - - it("ignores the redirect message if the direction is not provided", () => { - sendPortMessage(buttonPortSpy, { command: "redirectOverlayFocusOut" }); - - expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); - }); - - it("sends the redirect message if the direction is provided", () => { - sendPortMessage(buttonPortSpy, { - command: "redirectOverlayFocusOut", - direction: RedirectFocusDirection.Next, - }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "redirectOverlayFocusOut", - { direction: RedirectFocusDirection.Next }, - ); - }); - }); - }); - - describe("overlay list message handlers", () => { - describe("checkAutofillOverlayButtonFocused", () => { - it("checks on the focus state of the overlay button", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "checkAutofillOverlayButtonFocused" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); - }); - }); - - describe("forceCloseAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(listPortSpy, { command: "forceCloseAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - listPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: true }, - ); - }); - }); - - describe("overlayPageBlurred", () => { - it("checks on the focus state of the overlay button", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "overlayPageBlurred" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); - }); - }); - - describe("unlockVault", () => { - it("closes the autofill overlay and opens the unlock popout", async () => { - jest.spyOn(overlayBackground as any, "closeOverlay").mockImplementation(); - jest.spyOn(overlayBackground as any, "openUnlockPopout").mockImplementation(); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "unlockVault" }); - await flushPromises(); - - expect(overlayBackground["closeOverlay"]).toHaveBeenCalledWith(listPortSpy); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - listPortSpy.sender.tab, - "addToLockedVaultPendingNotifications", - { - commandToRetry: { - message: { command: "openAutofillOverlay" }, - sender: listPortSpy.sender, - }, - target: "overlay.background", - }, - ); - expect(overlayBackground["openUnlockPopout"]).toHaveBeenCalledWith( - listPortSpy.sender.tab, - true, - ); - }); - }); - - describe("fillSelectedListItem", () => { - let getLoginCiphersSpy: jest.SpyInstance; - let isPasswordRepromptRequiredSpy: jest.SpyInstance; - let doAutoFillSpy: jest.SpyInstance; - let sender: chrome.runtime.MessageSender; - const pageDetails = createAutofillPageDetailsMock({ - login: { username: "username1", password: "password1" }, - }); - - beforeEach(() => { - getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); - isPasswordRepromptRequiredSpy = jest.spyOn( - overlayBackground["autofillService"], - "isPasswordRepromptRequired", - ); - doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); - sender = mock({ tab: { id: 1 } }); - }); - - it("ignores the fill request if the overlay cipher id is not provided", async () => { - sendPortMessage(listPortSpy, { command: "fillSelectedListItem" }); - await flushPromises(); - - expect(getLoginCiphersSpy).not.toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("ignores the fill request if the tab does not contain any identified page details", async () => { - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(getLoginCiphersSpy).not.toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("ignores the fill request if a master password reprompt is required", async () => { - const cipher = mock({ - reprompt: CipherRepromptType.Password, - type: CipherType.Login, - }); - overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], - ]); - getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); - isPasswordRepromptRequiredSpy.mockResolvedValue(true); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(getLoginCiphersSpy).toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( - cipher, - listPortSpy.sender.tab, - ); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { - const cipher1 = mock({ id: "overlay-cipher-1" }); - const cipher2 = mock({ id: "overlay-cipher-2" }); - const cipher3 = mock({ id: "overlay-cipher-3" }); - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-1", cipher1], - ["overlay-cipher-2", cipher2], - ["overlay-cipher-3", cipher3], - ]); - const pageDetailsForTab = { - frameId: sender.frameId, - tab: sender.tab, - details: pageDetails, - }; - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, pageDetailsForTab], - ]); - isPasswordRepromptRequiredSpy.mockResolvedValue(false); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-2", - }); - await flushPromises(); - - expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( - cipher2, - listPortSpy.sender.tab, - ); - expect(doAutoFillSpy).toHaveBeenCalledWith({ - tab: listPortSpy.sender.tab, - cipher: cipher2, - pageDetails: [pageDetailsForTab], - fillNewPassword: true, - allowTotpAutofill: true, - }); - expect(overlayBackground["overlayLoginCiphers"].entries()).toStrictEqual( - new Map([ - ["overlay-cipher-2", cipher2], - ["overlay-cipher-1", cipher1], - ["overlay-cipher-3", cipher3], - ]).entries(), - ); - }); - - it("copies the cipher's totp code to the clipboard after filling", async () => { - const cipher1 = mock({ id: "overlay-cipher-1" }); - overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher1]]); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], - ]); - isPasswordRepromptRequiredSpy.mockResolvedValue(false); - const copyToClipboardSpy = jest - .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") - .mockImplementation(); - doAutoFillSpy.mockReturnValueOnce("totp-code"); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-2", - }); - await flushPromises(); - - expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); - }); - }); - - describe("getNewVaultItemDetails", () => { - it("will send an addNewVaultItemFromOverlay message", async () => { - jest.spyOn(BrowserApi, "tabSendMessage"); - - sendPortMessage(listPortSpy, { command: "addNewVaultItem" }); - await flushPromises(); - - expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(listPortSpy.sender.tab, { - command: "addNewVaultItemFromOverlay", - }); - }); - }); - - describe("viewSelectedCipher", () => { - let openViewVaultItemPopoutSpy: jest.SpyInstance; - - beforeEach(() => { - openViewVaultItemPopoutSpy = jest - .spyOn(overlayBackground as any, "openViewVaultItemPopout") - .mockImplementation(); - }); - - it("returns early if the passed cipher ID does not match one of the overlay login ciphers", async () => { - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], - ]); - - sendPortMessage(listPortSpy, { - command: "viewSelectedCipher", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); - }); - - it("will open the view vault item popout with the selected cipher", async () => { - const cipher = mock({ id: "overlay-cipher-1" }); - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], - ["overlay-cipher-1", cipher], - ]); - - sendPortMessage(listPortSpy, { - command: "viewSelectedCipher", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(overlayBackground["openViewVaultItemPopout"]).toHaveBeenCalledWith( - listPortSpy.sender.tab, - { - cipherId: cipher.id, - action: SHOW_AUTOFILL_BUTTON, - }, - ); - }); - }); - - describe("redirectOverlayFocusOut", () => { - it("redirects focus out of the overlay list", async () => { - const message = { - command: "redirectOverlayFocusOut", - direction: RedirectFocusDirection.Next, - }; - const redirectOverlayFocusOutSpy = jest.spyOn( - overlayBackground as any, - "redirectOverlayFocusOut", - ); - - sendPortMessage(listPortSpy, message); - await flushPromises(); - - expect(redirectOverlayFocusOutSpy).toHaveBeenCalledWith(message, listPortSpy); - }); - }); + expect(overlayBackground["inlineMenuListPort"]).toBeNull(); + expect(overlayBackground["inlineMenuButtonPort"]).toBeNull(); }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 2f80790134..3b770af200 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1,13 +1,18 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, merge, Subject, throttleTime } from "rxjs"; +import { debounceTime, switchMap } from "rxjs/operators"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; +import { + AutofillOverlayVisibility, + SHOW_AUTOFILL_BUTTON, +} from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -21,80 +26,118 @@ import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../../platform/browser/browser-api"; import { - openViewVaultItemPopout, openAddEditVaultItemPopout, + openViewVaultItemPopout, } from "../../vault/popup/utils/vault-popout-window"; -import { AutofillService, PageDetail } from "../services/abstractions/autofill.service"; -import { AutofillOverlayElement, AutofillOverlayPort } from "../utils/autofill-overlay.enum"; +import { + AutofillOverlayElement, + AutofillOverlayPort, + MAX_SUB_FRAME_DEPTH, +} from "../enums/autofill-overlay.enum"; +import { AutofillService } from "../services/abstractions/autofill.service"; +import { generateRandomChars } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { FocusedFieldData, - OverlayBackgroundExtensionMessageHandlers, - OverlayButtonPortMessageHandlers, - OverlayCipherData, - OverlayListPortMessageHandlers, + OverlayAddNewItemMessage, OverlayBackground as OverlayBackgroundInterface, OverlayBackgroundExtensionMessage, - OverlayAddNewItemMessage, + OverlayBackgroundExtensionMessageHandlers, + InlineMenuButtonPortMessageHandlers, + InlineMenuCipherData, + InlineMenuListPortMessageHandlers, OverlayPortMessage, - WebsiteIconData, + PageDetailsForTab, + SubFrameOffsetData, + SubFrameOffsetsForTab, + CloseInlineMenuMessage, + ToggleInlineMenuHiddenMessage, } from "./abstractions/overlay.background"; -class OverlayBackground implements OverlayBackgroundInterface { +export class OverlayBackground implements OverlayBackgroundInterface { private readonly openUnlockPopout = openUnlockPopout; private readonly openViewVaultItemPopout = openViewVaultItemPopout; private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; - private overlayLoginCiphers: Map = new Map(); - private pageDetailsForTab: Record< - chrome.runtime.MessageSender["tab"]["id"], - Map - > = {}; - private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; - private overlayButtonPort: chrome.runtime.Port; - private overlayListPort: chrome.runtime.Port; + private pageDetailsForTab: PageDetailsForTab = {}; + private subFrameOffsetsForTab: SubFrameOffsetsForTab = {}; + private portKeyForTab: Record = {}; private expiredPorts: chrome.runtime.Port[] = []; + private inlineMenuButtonPort: chrome.runtime.Port; + private inlineMenuListPort: chrome.runtime.Port; + private inlineMenuCiphers: Map = new Map(); + private inlineMenuPageTranslations: Record; + private delayedCloseTimeout: number | NodeJS.Timeout; + private startInlineMenuFadeInSubject = new Subject(); + private cancelInlineMenuFadeInSubject = new Subject(); + private startUpdateInlineMenuPositionSubject = new Subject(); + private cancelUpdateInlineMenuPositionSubject = new Subject(); + private repositionInlineMenuSubject = new Subject(); + private rebuildSubFrameOffsetsSubject = new Subject(); private focusedFieldData: FocusedFieldData; - private overlayPageTranslations: Record; + private isFieldCurrentlyFocused: boolean = false; + private isFieldCurrentlyFilling: boolean = false; + private isInlineMenuButtonVisible: boolean = false; + private isInlineMenuListVisible: boolean = false; private iconsServerUrl: string; private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { - openAutofillOverlay: () => this.openOverlay(false), autofillOverlayElementClosed: ({ message, sender }) => this.overlayElementClosed(message, sender), autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), - getAutofillOverlayVisibility: () => this.getOverlayVisibility(), - checkAutofillOverlayFocused: () => this.checkOverlayFocused(), - focusAutofillOverlayList: () => this.focusOverlayList(), - updateAutofillOverlayPosition: ({ message, sender }) => - this.updateOverlayPosition(message, sender), - updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message), + triggerAutofillOverlayReposition: ({ sender }) => this.triggerOverlayReposition(sender), + checkIsInlineMenuCiphersPopulated: ({ sender }) => + this.checkIsInlineMenuCiphersPopulated(sender), updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), + updateIsFieldCurrentlyFocused: ({ message }) => this.updateIsFieldCurrentlyFocused(message), + checkIsFieldCurrentlyFocused: () => this.checkIsFieldCurrentlyFocused(), + updateIsFieldCurrentlyFilling: ({ message }) => this.updateIsFieldCurrentlyFilling(message), + checkIsFieldCurrentlyFilling: () => this.checkIsFieldCurrentlyFilling(), + getAutofillInlineMenuVisibility: () => this.getInlineMenuVisibility(), + openAutofillInlineMenu: () => this.openInlineMenu(false), + closeAutofillInlineMenu: ({ message, sender }) => this.closeInlineMenu(sender, message), + checkAutofillInlineMenuFocused: ({ sender }) => this.checkInlineMenuFocused(sender), + focusAutofillInlineMenuList: () => this.focusInlineMenuList(), + updateAutofillInlineMenuPosition: ({ message, sender }) => + this.updateInlineMenuPosition(message, sender), + updateAutofillInlineMenuElementIsVisibleStatus: ({ message, sender }) => + this.updateInlineMenuElementIsVisibleStatus(message, sender), + checkIsAutofillInlineMenuButtonVisible: () => this.checkIsInlineMenuButtonVisible(), + checkIsAutofillInlineMenuListVisible: () => this.checkIsInlineMenuListVisible(), + getCurrentTabFrameId: ({ sender }) => this.getSenderFrameId(sender), + updateSubFrameData: ({ message, sender }) => this.updateSubFrameData(message, sender), + triggerSubFrameFocusInRebuild: ({ sender }) => this.triggerSubFrameFocusInRebuild(sender), + destroyAutofillInlineMenuListeners: ({ message, sender }) => + this.triggerDestroyInlineMenuListeners(sender.tab, message.subFrameData.frameId), collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), unlockCompleted: ({ message }) => this.unlockCompleted(message), + doFullSync: () => this.updateOverlayCiphers(), addedCipher: () => this.updateOverlayCiphers(), addEditCipherSubmitted: () => this.updateOverlayCiphers(), editedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(), }; - private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { - overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port), - closeAutofillOverlay: ({ port }) => this.closeOverlay(port), - forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), - overlayPageBlurred: () => this.checkOverlayListFocused(), - redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { + triggerDelayedAutofillInlineMenuClosure: ({ port }) => this.triggerDelayedInlineMenuClosure(), + autofillInlineMenuButtonClicked: ({ port }) => this.handleInlineMenuButtonClicked(port), + autofillInlineMenuBlurred: () => this.checkInlineMenuListFocused(), + redirectAutofillInlineMenuFocusOut: ({ message, port }) => + this.redirectInlineMenuFocusOut(message, port), + updateAutofillInlineMenuColorScheme: () => this.updateInlineMenuButtonColorScheme(), }; - private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = { - checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(), - forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), - overlayPageBlurred: () => this.checkOverlayButtonFocused(), + private readonly inlineMenuListPortMessageHandlers: InlineMenuListPortMessageHandlers = { + checkAutofillInlineMenuButtonFocused: () => this.checkInlineMenuButtonFocused(), + autofillInlineMenuBlurred: () => this.checkInlineMenuButtonFocused(), unlockVault: ({ port }) => this.unlockVault(port), - fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port), + fillAutofillInlineMenuCipher: ({ message, port }) => this.fillInlineMenuCipher(message, port), addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port), viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port), - redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + redirectAutofillInlineMenuFocusOut: ({ message, port }) => + this.redirectInlineMenuFocusOut(message, port), + updateAutofillInlineMenuListHeight: ({ message }) => this.updateInlineMenuListHeight(message), }; constructor( + private logService: LogService, private cipherService: CipherService, private autofillService: AutofillService, private authService: AuthService, @@ -104,7 +147,53 @@ class OverlayBackground implements OverlayBackgroundInterface { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private themeStateService: ThemeStateService, - ) {} + ) { + this.initOverlayEventObservables(); + } + + /** + * Sets up the extension message listeners and gets the settings for the + * overlay's visibility and the user's authentication status. + */ + async init() { + this.setupExtensionListeners(); + const env = await firstValueFrom(this.environmentService.environment$); + this.iconsServerUrl = env.getIconsUrl(); + } + + /** + * Initializes event observables that handle events which affect the overlay's behavior. + */ + private initOverlayEventObservables() { + this.repositionInlineMenuSubject + .pipe( + debounceTime(1000), + switchMap((sender) => this.repositionInlineMenu(sender)), + ) + .subscribe(); + this.rebuildSubFrameOffsetsSubject + .pipe( + throttleTime(100), + switchMap((sender) => this.rebuildSubFrameOffsets(sender)), + ) + .subscribe(); + + // Debounce used to update inline menu position + merge( + this.startUpdateInlineMenuPositionSubject.pipe(debounceTime(150)), + this.cancelUpdateInlineMenuPositionSubject, + ) + .pipe(switchMap((sender) => this.updateInlineMenuPositionAfterRepositionEvent(sender))) + .subscribe(); + + // FadeIn Observable behavior + merge( + this.startInlineMenuFadeInSubject.pipe(debounceTime(150)), + this.cancelInlineMenuFadeInSubject, + ) + .pipe(switchMap((cancelSignal) => this.triggerInlineMenuFadeIn(!!cancelSignal))) + .subscribe(); + } /** * Removes cached page details for a tab @@ -113,89 +202,83 @@ class OverlayBackground implements OverlayBackgroundInterface { * @param tabId - Used to reference the page details of a specific tab */ removePageDetails(tabId: number) { - if (!this.pageDetailsForTab[tabId]) { - return; + if (this.pageDetailsForTab[tabId]) { + this.pageDetailsForTab[tabId].clear(); + delete this.pageDetailsForTab[tabId]; } - this.pageDetailsForTab[tabId].clear(); - delete this.pageDetailsForTab[tabId]; + if (this.portKeyForTab[tabId]) { + delete this.portKeyForTab[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(); - const env = await firstValueFrom(this.environmentService.environment$); - this.iconsServerUrl = env.getIconsUrl(); - await this.getOverlayVisibility(); - await this.getAuthStatus(); - } - - /** - * Updates the overlay list's ciphers and sends the updated list to the overlay list iframe. + * Updates the inline menu list's ciphers and sends the updated list to the inline menu 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() { const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); if (authStatus !== AuthenticationStatus.Unlocked) { + if (this.focusedFieldData) { + void this.closeInlineMenuAfterCiphersUpdate(); + } return; } const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - if (!currentTab?.url) { - return; + if (this.focusedFieldData && currentTab?.id !== this.focusedFieldData.tabId) { + void this.closeInlineMenuAfterCiphersUpdate(); } - this.overlayLoginCiphers = new Map(); - const ciphersViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url)).sort( - (a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), - ); + this.inlineMenuCiphers = 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]); + this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); } - const ciphers = await this.getOverlayCipherData(); - this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers }); - await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", { - isOverlayCiphersPopulated: Boolean(ciphers.length), + const ciphers = await this.getInlineMenuCipherData(); + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuListCiphers", + ciphers, }); } /** * Strips out unnecessary data from the ciphers and returns an array of - * objects that contain the cipher data needed for the overlay list. + * objects that contain the cipher data needed for the inline menu list. */ - private async getOverlayCipherData(): Promise { + private async getInlineMenuCipherData(): Promise { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); - const overlayCiphersArray = Array.from(this.overlayLoginCiphers); - const overlayCipherData: OverlayCipherData[] = []; - let loginCipherIcon: WebsiteIconData; + const inlineMenuCiphersArray = Array.from(this.inlineMenuCiphers); + const inlineMenuCipherData: InlineMenuCipherData[] = []; - for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { - const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex]; - if (!loginCipherIcon && cipher.type === CipherType.Login) { - loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons); - } + for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { + const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex]; - overlayCipherData.push({ - id: overlayCipherId, + inlineMenuCipherData.push({ + id: inlineMenuCipherId, name: cipher.name, type: cipher.type, reprompt: cipher.reprompt, favorite: cipher.favorite, - icon: - cipher.type === CipherType.Login - ? loginCipherIcon - : buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), + icon: buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null, card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, }); } - return overlayCipherData; + return inlineMenuCipherData; + } + + /** + * Gets the currently focused field and closes the inline menu on that tab. + */ + private async closeInlineMenuAfterCiphersUpdate() { + const focusedFieldTab = await BrowserApi.getTab(this.focusedFieldData.tabId); + this.closeInlineMenu({ tab: focusedFieldTab }, { forceCloseInlineMenu: true }); } /** @@ -215,6 +298,13 @@ class OverlayBackground implements OverlayBackgroundInterface { details: message.details, }; + if (pageDetails.frameId !== 0 && pageDetails.details.fields.length) { + void this.buildSubFrameOffsets(pageDetails.tab, pageDetails.frameId, pageDetails.details.url); + void BrowserApi.tabSendMessage(pageDetails.tab, { + command: "setupRebuildSubFrameOffsetsListeners", + }); + } + const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; if (!pageDetailsMap) { this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]); @@ -225,22 +315,205 @@ class OverlayBackground implements OverlayBackgroundInterface { } /** - * Triggers autofill for the selected cipher in the overlay list. Also places - * the selected cipher at the top of the list of ciphers. + * Returns the frameId, called when calculating sub frame offsets within the tab. + * Is used to determine if we should reposition the inline menu when a resize event + * occurs within a frame. * - * @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 + * @param sender - The sender of the message */ - private async fillSelectedOverlayListItem( - { overlayCipherId }: OverlayPortMessage, - { sender }: chrome.runtime.Port, + private getSenderFrameId(sender: chrome.runtime.MessageSender) { + return sender.frameId; + } + + /** + * Handles sub frame offset calculations for the given tab and frame id. + * Is used in setting the position of the inline menu list and button. + * + * @param message - The message received from the `updateSubFrameData` command + * @param sender - The sender of the message + */ + private updateSubFrameData( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, ) { - const pageDetails = this.pageDetailsForTab[sender.tab.id]; - if (!overlayCipherId || !pageDetails?.size) { + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; + if (subFrameOffsetsForTab) { + subFrameOffsetsForTab.set(message.subFrameData.frameId, message.subFrameData); + } + } + + /** + * Builds the offset data for a sub frame of a tab. The offset data is used + * to calculate the position of the inline menu list and button. + * + * @param tab - The tab that the sub frame is associated with + * @param frameId - The frame ID of the sub frame + * @param url - The URL of the sub frame + * @param forceRebuild - Identifies whether the sub frame offsets should be rebuilt + */ + private async buildSubFrameOffsets( + tab: chrome.tabs.Tab, + frameId: number, + url: string, + forceRebuild: boolean = false, + ) { + let subFrameDepth = 0; + const tabId = tab.id; + let subFrameOffsetsForTab = this.subFrameOffsetsForTab[tabId]; + if (!subFrameOffsetsForTab) { + this.subFrameOffsetsForTab[tabId] = new Map(); + subFrameOffsetsForTab = this.subFrameOffsetsForTab[tabId]; + } + + if (!forceRebuild && subFrameOffsetsForTab.get(frameId)) { return; } - const cipher = this.overlayLoginCiphers.get(overlayCipherId); + const subFrameData: SubFrameOffsetData = { url, top: 0, left: 0, parentFrameIds: [0] }; + let frameDetails = await BrowserApi.getFrameDetails({ tabId, frameId }); + + while (frameDetails && frameDetails.parentFrameId > -1) { + subFrameDepth++; + if (subFrameDepth >= MAX_SUB_FRAME_DEPTH) { + subFrameOffsetsForTab.set(frameId, null); + this.triggerDestroyInlineMenuListeners(tab, frameId); + return; + } + + const subFrameOffset: SubFrameOffsetData = await BrowserApi.tabSendMessage( + tab, + { + command: "getSubFrameOffsets", + subFrameUrl: frameDetails.url, + subFrameId: frameDetails.documentId, + }, + { frameId: frameDetails.parentFrameId }, + ); + + if (!subFrameOffset) { + subFrameOffsetsForTab.set(frameId, null); + void BrowserApi.tabSendMessage( + tab, + { command: "getSubFrameOffsetsFromWindowMessage", subFrameId: frameId }, + { frameId }, + ); + return; + } + + subFrameData.top += subFrameOffset.top; + subFrameData.left += subFrameOffset.left; + if (!subFrameData.parentFrameIds.includes(frameDetails.parentFrameId)) { + subFrameData.parentFrameIds.push(frameDetails.parentFrameId); + } + + frameDetails = await BrowserApi.getFrameDetails({ + tabId, + frameId: frameDetails.parentFrameId, + }); + } + + subFrameOffsetsForTab.set(frameId, subFrameData); + } + + /** + * Triggers a removal and destruction of all + * + * @param tab - The tab that the sub frame is associated with + * @param frameId - The frame ID of the sub frame + */ + private triggerDestroyInlineMenuListeners(tab: chrome.tabs.Tab, frameId: number) { + this.logService.error( + "Excessive frame depth encountered, destroying inline menu on field within frame", + tab, + frameId, + ); + + void BrowserApi.tabSendMessage( + tab, + { command: "destroyAutofillInlineMenuListeners" }, + { frameId }, + ); + } + + /** + * Rebuilds the sub frame offsets for the tab associated with the sender. + * + * @param sender - The sender of the message + */ + private async rebuildSubFrameOffsets(sender: chrome.runtime.MessageSender) { + this.cancelUpdateInlineMenuPositionSubject.next(); + this.clearDelayedInlineMenuClosure(); + + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; + if (subFrameOffsetsForTab) { + const tabFrameIds = Array.from(subFrameOffsetsForTab.keys()); + for (const frameId of tabFrameIds) { + await this.buildSubFrameOffsets(sender.tab, frameId, sender.url, true); + } + } + } + + /** + * Handles updating the inline menu's position after rebuilding the sub frames + * for the provided tab. Will skip repositioning the inline menu if the field + * is not currently focused, or if the focused field has a value. + * + * @param sender - The sender of the message + */ + private async updateInlineMenuPositionAfterRepositionEvent( + sender: chrome.runtime.MessageSender | void, + ) { + if (!sender || !this.isFieldCurrentlyFocused) { + return; + } + + if (!this.checkIsInlineMenuButtonVisible()) { + void this.toggleInlineMenuHidden( + { isInlineMenuHidden: false, setTransparentInlineMenu: true }, + sender, + ); + } + + void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender); + + const mostRecentlyFocusedFieldHasValue = await BrowserApi.tabSendMessage( + sender.tab, + { command: "checkMostRecentlyFocusedFieldHasValue" }, + { frameId: this.focusedFieldData?.frameId }, + ); + + if ((await this.getInlineMenuVisibility()) === AutofillOverlayVisibility.OnButtonClick) { + return; + } + + if ( + mostRecentlyFocusedFieldHasValue && + (this.checkIsInlineMenuCiphersPopulated(sender) || + (await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) + ) { + return; + } + + void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender); + } + + /** + * Triggers autofill for the selected cipher in the inline menu list. Also places + * the selected cipher at the top of the list of ciphers. + * + * @param inlineMenuCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. + * @param sender - The sender of the port message + */ + private async fillInlineMenuCipher( + { inlineMenuCipherId }: OverlayPortMessage, + { sender }: chrome.runtime.Port, + ) { + const pageDetails = this.pageDetailsForTab[sender.tab.id]; + if (!inlineMenuCipherId || !pageDetails?.size) { + return; + } + + const cipher = this.inlineMenuCiphers.get(inlineMenuCipherId); if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { return; @@ -257,47 +530,117 @@ class OverlayBackground implements OverlayBackgroundInterface { this.platformUtilsService.copyToClipboard(totpCode); } - this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); + this.inlineMenuCiphers = new Map([[inlineMenuCipherId, cipher], ...this.inlineMenuCiphers]); } /** - * Checks if the overlay is focused. Will check the overlay list - * if it is open, otherwise it will check the overlay button. + * Checks if the inline menu is focused. Will check the inline menu list + * if it is open, otherwise it will check the inline menu button. */ - private checkOverlayFocused() { - if (this.overlayListPort) { - this.checkOverlayListFocused(); + private checkInlineMenuFocused(sender: chrome.runtime.MessageSender) { + if (!this.senderTabHasFocusedField(sender)) { + return; + } + + if (this.inlineMenuListPort) { + this.checkInlineMenuListFocused(); return; } - this.checkOverlayButtonFocused(); + this.checkInlineMenuButtonFocused(); } /** - * Posts a message to the overlay button iframe to check if it is focused. + * Posts a message to the inline menu button iframe to check if it is focused. */ - private checkOverlayButtonFocused() { - this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" }); + private checkInlineMenuButtonFocused() { + this.inlineMenuButtonPort?.postMessage({ command: "checkAutofillInlineMenuButtonFocused" }); } /** - * Posts a message to the overlay list iframe to check if it is focused. + * Posts a message to the inline menu list iframe to check if it is focused. */ - private checkOverlayListFocused() { - this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" }); + private checkInlineMenuListFocused() { + this.inlineMenuListPort?.postMessage({ command: "checkAutofillInlineMenuListFocused" }); } /** - * Sends a message to the sender tab to close the autofill overlay. + * Sends a message to the sender tab to close the autofill inline menu. * * @param sender - The sender of the port message - * @param forceCloseOverlay - Identifies whether the overlay should be force closed + * @param forceCloseInlineMenu - Identifies whether the inline menu should be forced closed + * @param overlayElement - The overlay element to close, either the list or button */ - private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay }); + private closeInlineMenu( + sender: chrome.runtime.MessageSender, + { forceCloseInlineMenu, overlayElement }: CloseInlineMenuMessage = {}, + ) { + const command = "closeAutofillInlineMenu"; + const sendOptions = { frameId: 0 }; + if (forceCloseInlineMenu) { + void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions); + this.isInlineMenuButtonVisible = false; + this.isInlineMenuListVisible = false; + return; + } + + if (this.isFieldCurrentlyFocused) { + return; + } + + if (this.isFieldCurrentlyFilling) { + void BrowserApi.tabSendMessage( + sender.tab, + { command, overlayElement: AutofillOverlayElement.List }, + sendOptions, + ); + this.isInlineMenuListVisible = false; + return; + } + + if (overlayElement === AutofillOverlayElement.Button) { + this.isInlineMenuButtonVisible = false; + } + + if (overlayElement === AutofillOverlayElement.List) { + this.isInlineMenuListVisible = false; + } + + if (!overlayElement) { + this.isInlineMenuButtonVisible = false; + this.isInlineMenuListVisible = false; + } + + void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions); + } + + /** + * Sends a message to the sender tab to trigger a delayed closure of the inline menu. + * This is used to ensure that we capture click events on the inline menu in the case + * that some on page programmatic method attempts to force focus redirection. + */ + private triggerDelayedInlineMenuClosure() { + if (this.isFieldCurrentlyFocused) { + return; + } + + this.clearDelayedInlineMenuClosure(); + this.delayedCloseTimeout = globalThis.setTimeout(() => { + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + this.inlineMenuButtonPort?.postMessage(message); + this.inlineMenuListPort?.postMessage(message); + }, 100); + } + + /** + * Clears the delayed closure timeout for the inline menu, effectively + * cancelling the event from occurring. + */ + private clearDelayedInlineMenuClosure() { + if (this.delayedCloseTimeout) { + clearTimeout(this.delayedCloseTimeout); + } } /** @@ -311,61 +654,141 @@ class OverlayBackground implements OverlayBackgroundInterface { { overlayElement }: OverlayBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - if (sender.tab.id !== this.focusedFieldData?.tabId) { + if (!this.senderTabHasFocusedField(sender)) { this.expiredPorts.forEach((port) => port.disconnect()); this.expiredPorts = []; + return; } if (overlayElement === AutofillOverlayElement.Button) { - this.overlayButtonPort?.disconnect(); - this.overlayButtonPort = null; + this.inlineMenuButtonPort?.disconnect(); + this.inlineMenuButtonPort = null; + this.isInlineMenuButtonVisible = false; return; } - this.overlayListPort?.disconnect(); - this.overlayListPort = null; + this.inlineMenuListPort?.disconnect(); + this.inlineMenuListPort = null; + this.isInlineMenuListVisible = false; } /** - * Updates the position of either the overlay list or button. The position + * Updates the position of either the inline menu 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 * @param sender - The sender of the port message */ - private updateOverlayPosition( + private async updateInlineMenuPosition( { overlayElement }: { overlayElement?: string }, sender: chrome.runtime.MessageSender, ) { - if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) { + if (!overlayElement || !this.senderTabHasFocusedField(sender)) { return; } + this.cancelInlineMenuFadeInAndPositionUpdate(); + + await BrowserApi.tabSendMessage( + sender.tab, + { command: "appendAutofillInlineMenuToDom", overlayElement }, + { frameId: 0 }, + ); + + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[this.focusedFieldData.tabId]; + let subFrameOffsets: SubFrameOffsetData; + if (subFrameOffsetsForTab) { + subFrameOffsets = subFrameOffsetsForTab.get(this.focusedFieldData.frameId); + if (subFrameOffsets === null) { + this.rebuildSubFrameOffsetsSubject.next(sender); + this.startUpdateInlineMenuPositionSubject.next(sender); + return; + } + } + if (overlayElement === AutofillOverlayElement.Button) { - this.overlayButtonPort?.postMessage({ - command: "updateIframePosition", - styles: this.getOverlayButtonPosition(), + this.inlineMenuButtonPort?.postMessage({ + command: "updateAutofillInlineMenuPosition", + styles: this.getInlineMenuButtonPosition(subFrameOffsets), }); + this.startInlineMenuFadeIn(); return; } - this.overlayListPort?.postMessage({ - command: "updateIframePosition", - styles: this.getOverlayListPosition(), + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuPosition", + styles: this.getInlineMenuListPosition(subFrameOffsets), }); + this.startInlineMenuFadeIn(); + } + + /** + * Triggers an update of the inline menu's visibility after the top level frame + * appends the element to the DOM. + * + * @param message - The message received from the content script + * @param sender - The sender of the port message + */ + private updateInlineMenuElementIsVisibleStatus( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + if (!this.senderTabHasFocusedField(sender)) { + return; + } + + const { overlayElement, isVisible } = message; + if (overlayElement === AutofillOverlayElement.Button) { + this.isInlineMenuButtonVisible = isVisible; + return; + } + + if (overlayElement === AutofillOverlayElement.List) { + this.isInlineMenuListVisible = isVisible; + } + } + + /** + * Handles updating the opacity of both the inline menu button and list. + * This is used to simultaneously fade in the inline menu elements. + */ + private startInlineMenuFadeIn() { + this.cancelInlineMenuFadeIn(); + this.startInlineMenuFadeInSubject.next(); + } + + /** + * Clears the timeout used to fade in the inline menu elements. + */ + private cancelInlineMenuFadeIn() { + this.cancelInlineMenuFadeInSubject.next(true); + } + + /** + * Posts a message to the inline menu elements to trigger a fade in of the inline menu. + * + * @param cancelFadeIn - Signal passed to debounced observable to cancel the fade in + */ + private async triggerInlineMenuFadeIn(cancelFadeIn: boolean = false) { + if (cancelFadeIn) { + return; + } + + const message = { command: "fadeInAutofillInlineMenuIframe" }; + this.inlineMenuButtonPort?.postMessage(message); + this.inlineMenuListPort?.postMessage(message); } /** * Gets the position of the focused field and calculates the position - * of the overlay button based on the focused field's position and dimensions. + * of the inline menu button based on the focused field's position and dimensions. */ - private getOverlayButtonPosition() { - if (!this.focusedFieldData) { - return; - } + private getInlineMenuButtonPosition(subFrameOffsets: SubFrameOffsetData) { + const subFrameTopOffset = subFrameOffsets?.top || 0; + const subFrameLeftOffset = subFrameOffsets?.left || 0; const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles; @@ -374,15 +797,15 @@ class OverlayBackground implements OverlayBackgroundInterface { 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); - } + const elementHeight = height - elementOffset; + + const elementTopPosition = subFrameTopOffset + top + elementOffset / 2; + const elementLeftPosition = + fieldPaddingRight > fieldPaddingLeft + ? subFrameLeftOffset + left + width - height - (fieldPaddingRight - elementOffset + 2) + : subFrameLeftOffset + left + width - height + elementOffset / 2; return { top: `${Math.round(elementTopPosition)}px`, @@ -394,18 +817,17 @@ class OverlayBackground implements OverlayBackgroundInterface { /** * Gets the position of the focused field and calculates the position - * of the overlay list based on the focused field's position and dimensions. + * of the inline menu list based on the focused field's position and dimensions. */ - private getOverlayListPosition() { - if (!this.focusedFieldData) { - return; - } + private getInlineMenuListPosition(subFrameOffsets: SubFrameOffsetData) { + const subFrameTopOffset = subFrameOffsets?.top || 0; + const subFrameLeftOffset = subFrameOffsets?.left || 0; 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`, + top: `${Math.round(top + height + subFrameTopOffset)}px`, + left: `${Math.round(left + subFrameLeftOffset)}px`, }; } @@ -419,109 +841,137 @@ class OverlayBackground implements OverlayBackgroundInterface { { focusedFieldData }: OverlayBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id }; + if (this.focusedFieldData?.frameId && this.focusedFieldData.frameId !== sender.frameId) { + void BrowserApi.tabSendMessage( + sender.tab, + { command: "unsetMostRecentlyFocusedField" }, + { frameId: this.focusedFieldData.frameId }, + ); + } + + this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId }; } /** - * Updates the overlay's visibility based on the display property passed in the extension message. + * Updates the inline menu's visibility based on the display property passed in the extension message. * - * @param display - The display property of the overlay, either "block" or "none" + * @param display - The display property of the inline menu, either "block" or "none" + * @param sender - The sender of the extension message */ - private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) { - if (!display) { + private async toggleInlineMenuHidden( + { isInlineMenuHidden, setTransparentInlineMenu }: ToggleInlineMenuHiddenMessage, + sender: chrome.runtime.MessageSender, + ) { + if (!this.senderTabHasFocusedField(sender)) { return; } - const portMessage = { command: "updateOverlayHidden", styles: { display } }; + this.cancelInlineMenuFadeIn(); + const display = isInlineMenuHidden ? "none" : "block"; + let styles: { display: string; opacity?: string } = { display }; - this.overlayButtonPort?.postMessage(portMessage); - this.overlayListPort?.postMessage(portMessage); + if (typeof setTransparentInlineMenu !== "undefined") { + const opacity = setTransparentInlineMenu ? "0" : "1"; + styles = { ...styles, opacity }; + } + + const portMessage = { command: "toggleAutofillInlineMenuHidden", styles }; + if (this.inlineMenuButtonPort) { + this.isInlineMenuButtonVisible = !isInlineMenuHidden; + this.inlineMenuButtonPort.postMessage(portMessage); + } + + if (this.inlineMenuListPort) { + this.isInlineMenuListVisible = !isInlineMenuHidden; + this.inlineMenuListPort.postMessage(portMessage); + } + + if (setTransparentInlineMenu) { + this.startInlineMenuFadeIn(); + } } /** - * Sends a message to the currently active tab to open the autofill overlay. + * Sends a message to the currently active tab to open the autofill inline menu. * - * @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 + * @param isFocusingFieldElement - Identifies whether the field element should be focused when the inline menu is opened + * @param isOpeningFullInlineMenu - Identifies whether the full inline menu should be forced open regardless of other states */ - private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) { + private async openInlineMenu(isFocusingFieldElement = false, isOpeningFullInlineMenu = false) { + this.clearDelayedInlineMenuClosure(); const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", { - isFocusingFieldElement, - isOpeningFullOverlay, + await BrowserApi.tabSendMessage( + currentTab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement, + isOpeningFullInlineMenu, + authStatus: await this.getAuthStatus(), + }, + { + frameId: + this.focusedFieldData?.tabId === currentTab?.id ? this.focusedFieldData.frameId : 0, + }, + ); + } + + /** + * Gets the inline menu's visibility setting from the settings service. + */ + private async getInlineMenuVisibility(): Promise { + return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); + } + + /** + * Gets the user's authentication status from the auth service. If the user's authentication + * status has changed, the inline menu button's authentication status will be updated + * and the inline menu list's ciphers will be updated. + */ + private async getAuthStatus() { + return await firstValueFrom(this.authService.activeAccountStatus$); + } + + /** + * Sends a message to the inline menu button to update its authentication status. + */ + private async updateInlineMenuButtonAuthStatus() { + this.inlineMenuButtonPort?.postMessage({ + command: "updateInlineMenuButtonAuthStatus", authStatus: await this.getAuthStatus(), }); } /** - * Gets the overlay's visibility setting from the settings service. - */ - private async getOverlayVisibility(): Promise { - return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); - } - - /** - * 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; - } - - /** - * 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 + * Handles the inline menu button being clicked. If the user is not authenticated, + * the vault will be unlocked. If the user is authenticated, the inline menu will * be opened. * - * @param port - The port of the overlay button + * @param port - The port of the inline menu button */ - private handleOverlayButtonClicked(port: chrome.runtime.Port) { - if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.unlockVault(port); + private async handleInlineMenuButtonClicked(port: chrome.runtime.Port) { + this.clearDelayedInlineMenuClosure(); + this.cancelInlineMenuFadeInAndPositionUpdate(); + + if ((await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) { + await this.unlockVault(port); return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.openOverlay(false, true); + await this.openInlineMenu(false, true); } /** * Facilitates opening the unlock popout window. * - * @param port - The port of the overlay list + * @param port - The port of the inline menu list */ private async unlockVault(port: chrome.runtime.Port) { const { sender } = port; - this.closeOverlay(port); + this.closeInlineMenu(port.sender); const retryMessage: LockedVaultPendingNotificationsData = { - commandToRetry: { message: { command: "openAutofillOverlay" }, sender }, + commandToRetry: { message: { command: "openAutofillInlineMenu" }, sender }, target: "overlay.background", }; await BrowserApi.tabSendMessageData( @@ -535,18 +985,19 @@ class OverlayBackground implements OverlayBackgroundInterface { /** * 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 inlineMenuCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. * @param sender - The sender of the port message */ private async viewSelectedCipher( - { overlayCipherId }: OverlayPortMessage, + { inlineMenuCipherId }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { - const cipher = this.overlayLoginCiphers.get(overlayCipherId); + const cipher = this.inlineMenuCiphers.get(inlineMenuCipherId); if (!cipher) { return; } + this.closeInlineMenu(sender); await this.openViewVaultItemPopout(sender.tab, { cipherId: cipher.id, action: SHOW_AUTOFILL_BUTTON, @@ -554,32 +1005,33 @@ class OverlayBackground implements OverlayBackgroundInterface { } /** - * Facilitates redirecting focus to the overlay list. + * Facilitates redirecting focus to the inline menu list. */ - private focusOverlayList() { - this.overlayListPort?.postMessage({ command: "focusOverlayList" }); + private focusInlineMenuList() { + this.inlineMenuListPort?.postMessage({ command: "focusAutofillInlineMenuList" }); } /** - * Updates the authentication status for the user and opens the overlay if + * Updates the authentication status for the user and opens the inline menu 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(); + await this.updateInlineMenuButtonAuthStatus(); + await this.updateOverlayCiphers(); - if (message.data?.commandToRetry?.message?.command === "openAutofillOverlay") { - await this.openOverlay(true); + if (message.data?.commandToRetry?.message?.command === "openAutofillInlineMenu") { + await this.openInlineMenu(true); } } /** - * Gets the translations for the overlay page. + * Gets the translations for the inline menu page. */ - private getTranslations() { - if (!this.overlayPageTranslations) { - this.overlayPageTranslations = { + private getInlineMenuTranslations() { + if (!this.inlineMenuPageTranslations) { + this.inlineMenuPageTranslations = { locale: BrowserApi.getUILanguage(), opensInANewWindow: this.i18nService.translate("opensInANewWindow"), buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"), @@ -588,7 +1040,7 @@ class OverlayBackground implements OverlayBackgroundInterface { unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"), unlockAccount: this.i18nService.translate("unlockAccount"), fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), - partialUsername: this.i18nService.translate("partialUsername"), + username: this.i18nService.translate("username")?.toLowerCase(), view: this.i18nService.translate("view"), noItemsToShow: this.i18nService.translate("noItemsToShow"), newItem: this.i18nService.translate("newItem"), @@ -596,17 +1048,17 @@ class OverlayBackground implements OverlayBackgroundInterface { }; } - return this.overlayPageTranslations; + return this.inlineMenuPageTranslations; } /** * Facilitates redirecting focus out of one of the - * overlay elements to elements on the page. + * inline menu 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( + private redirectInlineMenuFocusOut( { direction }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { @@ -614,9 +1066,9 @@ class OverlayBackground implements OverlayBackgroundInterface { return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction }); + void BrowserApi.tabSendMessageData(sender.tab, "redirectAutofillInlineMenuFocusOut", { + direction, + }); } /** @@ -626,7 +1078,17 @@ class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the port message */ private getNewVaultItemDetails({ sender }: chrome.runtime.Port) { - void BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" }); + if (!this.senderTabHasFocusedField(sender)) { + return; + } + + void BrowserApi.tabSendMessage( + sender.tab, + { command: "addNewVaultItemFromOverlay" }, + { + frameId: this.focusedFieldData.frameId || 0, + }, + ); } /** @@ -644,6 +1106,7 @@ class OverlayBackground implements OverlayBackgroundInterface { return; } + this.closeInlineMenu(sender); const uriView = new LoginUriView(); uriView.uri = login.uri; @@ -667,11 +1130,222 @@ class OverlayBackground implements OverlayBackgroundInterface { await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); } + /** + * Updates the property that identifies if a form field set up for the inline menu is currently focused. + * + * @param message - The message received from the web page + */ + private updateIsFieldCurrentlyFocused(message: OverlayBackgroundExtensionMessage) { + this.isFieldCurrentlyFocused = message.isFieldCurrentlyFocused; + } + + /** + * Allows a content script to check if a form field setup for the inline menu is currently focused. + */ + private checkIsFieldCurrentlyFocused() { + return this.isFieldCurrentlyFocused; + } + + /** + * Updates the property that identifies if a form field is currently being autofilled. + * + * @param message - The message received from the web page + */ + private updateIsFieldCurrentlyFilling(message: OverlayBackgroundExtensionMessage) { + this.isFieldCurrentlyFilling = message.isFieldCurrentlyFilling; + } + + /** + * Allows a content script to check if a form field is currently being autofilled. + */ + private checkIsFieldCurrentlyFilling() { + return this.isFieldCurrentlyFilling; + } + + /** + * Returns the visibility status of the inline menu button. + */ + private checkIsInlineMenuButtonVisible(): boolean { + return this.isInlineMenuButtonVisible; + } + + /** + * Returns the visibility status of the inline menu list. + */ + private checkIsInlineMenuListVisible(): boolean { + return this.isInlineMenuListVisible; + } + + /** + * Responds to the content script's request to check if the inline menu ciphers are populated. + * This will return true only if the sender is the focused field's tab and the inline menu + * ciphers are populated. + * + * @param sender - The sender of the message + */ + private checkIsInlineMenuCiphersPopulated(sender: chrome.runtime.MessageSender) { + return this.senderTabHasFocusedField(sender) && this.inlineMenuCiphers.size > 0; + } + + /** + * Triggers an update in the meta "color-scheme" value within the inline menu button. + * This is done to ensure that the button element has a transparent background, which + * is accomplished by setting the "color-scheme" meta value of the button iframe to + * the same value as the page's meta "color-scheme" value. + */ + private updateInlineMenuButtonColorScheme() { + this.inlineMenuButtonPort?.postMessage({ + command: "updateAutofillInlineMenuColorScheme", + }); + } + + /** + * Triggers an update in the inline menu list's height. + * + * @param message - Contains the dimensions of the inline menu list + */ + private updateInlineMenuListHeight(message: OverlayBackgroundExtensionMessage) { + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuPosition", + styles: message.styles, + }); + } + + /** + * Handles verifying whether the inline menu should be repositioned. This is used to + * guard against removing the inline menu when other frames trigger a resize event. + * + * @param sender - The sender of the message + */ + private checkShouldRepositionInlineMenu(sender: chrome.runtime.MessageSender): boolean { + if (!this.focusedFieldData || !this.senderTabHasFocusedField(sender)) { + return false; + } + + if (this.focusedFieldData?.frameId === sender.frameId) { + return true; + } + + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; + if (subFrameOffsetsForTab) { + for (const value of subFrameOffsetsForTab.values()) { + if (value?.parentFrameIds.includes(sender.frameId)) { + return true; + } + } + } + + return false; + } + + /** + * Identifies if the sender tab is the same as the focused field's tab. + * + * @param sender - The sender of the message + */ + private senderTabHasFocusedField(sender: chrome.runtime.MessageSender) { + return sender.tab.id === this.focusedFieldData?.tabId; + } + + /** + * Triggers when a scroll or resize event occurs within a tab. Will reposition the inline menu + * if the focused field is within the viewport. + * + * @param sender - The sender of the message + */ + private async triggerOverlayReposition(sender: chrome.runtime.MessageSender) { + if (!this.checkShouldRepositionInlineMenu(sender)) { + return; + } + + this.resetFocusedFieldSubFrameOffsets(sender); + this.cancelInlineMenuFadeInAndPositionUpdate(); + void this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender); + this.repositionInlineMenuSubject.next(sender); + } + + /** + * Sets the sub frame offsets for the currently focused field's frame to a null value . + * This ensures that we can delay presentation of the inline menu after a reposition + * event if the user clicks on a field before the sub frames can be rebuilt. + * + * @param sender + */ + private resetFocusedFieldSubFrameOffsets(sender: chrome.runtime.MessageSender) { + if (this.focusedFieldData.frameId > 0 && this.subFrameOffsetsForTab[sender.tab.id]) { + this.subFrameOffsetsForTab[sender.tab.id].set(this.focusedFieldData.frameId, null); + } + } + + /** + * Triggers when a focus event occurs within a tab. Will reposition the inline menu + * if the focused field is within the viewport. + * + * @param sender - The sender of the message + */ + private async triggerSubFrameFocusInRebuild(sender: chrome.runtime.MessageSender) { + this.cancelInlineMenuFadeInAndPositionUpdate(); + this.rebuildSubFrameOffsetsSubject.next(sender); + this.repositionInlineMenuSubject.next(sender); + } + + /** + * Handles determining if the inline menu should be repositioned or closed, and initiates + * the process of calculating the new position of the inline menu. + * + * @param sender - The sender of the message + */ + private repositionInlineMenu = async (sender: chrome.runtime.MessageSender) => { + this.cancelInlineMenuFadeInAndPositionUpdate(); + if (!this.isFieldCurrentlyFocused && !this.isInlineMenuButtonVisible) { + await this.closeInlineMenuAfterReposition(sender); + return; + } + + const isFieldWithinViewport = await BrowserApi.tabSendMessage( + sender.tab, + { command: "checkIsMostRecentlyFocusedFieldWithinViewport" }, + { frameId: this.focusedFieldData.frameId }, + ); + if (!isFieldWithinViewport) { + await this.closeInlineMenuAfterReposition(sender); + return; + } + + if (this.focusedFieldData.frameId > 0) { + this.rebuildSubFrameOffsetsSubject.next(sender); + } + + this.startUpdateInlineMenuPositionSubject.next(sender); + }; + + /** + * Triggers a closure of the inline menu during a reposition event. + * + * @param sender - The sender of the message +| */ + private async closeInlineMenuAfterReposition(sender: chrome.runtime.MessageSender) { + await this.toggleInlineMenuHidden( + { isInlineMenuHidden: false, setTransparentInlineMenu: true }, + sender, + ); + this.closeInlineMenu(sender, { forceCloseInlineMenu: true }); + } + + /** + * Cancels the observables that update the position and fade in of the inline menu. + */ + private cancelInlineMenuFadeInAndPositionUpdate() { + this.cancelInlineMenuFadeIn(); + this.cancelUpdateInlineMenuPositionSubject.next(); + } + /** * Sets up the extension message listeners for the overlay. */ - private setupExtensionMessageListeners() { + private setupExtensionListeners() { BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); + BrowserApi.addListener(chrome.webNavigation.onCommitted, this.handleWebNavigationOnCommitted); BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect); } @@ -689,18 +1363,42 @@ class OverlayBackground implements OverlayBackgroundInterface { ) => { const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; if (!handler) { - return; + return null; } const messageResponse = handler({ message, sender }); - if (!messageResponse) { + if (typeof messageResponse === "undefined") { + return null; + } + + Promise.resolve(messageResponse) + .then((response) => sendResponse(response)) + .catch(this.logService.error); + return true; + }; + + /** + * Handles clearing page details and sub frame offsets when a frame or tab navigation event occurs. + * + * @param details - The details of the web navigation event + */ + private handleWebNavigationOnCommitted = ( + details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, + ) => { + const { frameId, tabId } = details; + const subFrames = this.subFrameOffsetsForTab[tabId]; + if (frameId === 0) { + this.removePageDetails(tabId); + if (subFrames) { + subFrames.clear(); + delete this.subFrameOffsetsForTab[tabId]; + } return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve(messageResponse).then((response) => sendResponse(response)); - return true; + if (subFrames && subFrames.has(frameId)) { + subFrames.delete(frameId); + } }; /** @@ -709,25 +1407,50 @@ class OverlayBackground implements OverlayBackgroundInterface { * @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) { + const isInlineMenuListMessageConnector = port.name === AutofillOverlayPort.ListMessageConnector; + const isInlineMenuButtonMessageConnector = + port.name === AutofillOverlayPort.ButtonMessageConnector; + if (isInlineMenuListMessageConnector || isInlineMenuButtonMessageConnector) { + port.onMessage.addListener(this.handleOverlayElementPortMessage); return; } + const isInlineMenuListPort = port.name === AutofillOverlayPort.List; + const isInlineMenuButtonPort = port.name === AutofillOverlayPort.Button; + if (!isInlineMenuListPort && !isInlineMenuButtonPort) { + return; + } + + if (!this.portKeyForTab[port.sender.tab.id]) { + this.portKeyForTab[port.sender.tab.id] = generateRandomChars(12); + } + this.storeOverlayPort(port); + port.onDisconnect.addListener(this.handlePortOnDisconnect); port.onMessage.addListener(this.handleOverlayElementPortMessage); port.postMessage({ - command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, + command: `initAutofillInlineMenu${isInlineMenuListPort ? "List" : "Button"}`, + iframeUrl: chrome.runtime.getURL( + `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.html`, + ), + pageTitle: chrome.i18n.getMessage( + isInlineMenuListPort ? "bitwardenVault" : "bitwardenOverlayButton", + ), authStatus: await this.getAuthStatus(), - styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), + styleSheetUrl: chrome.runtime.getURL( + `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.css`, + ), theme: await firstValueFrom(this.themeStateService.selectedTheme$), - translations: this.getTranslations(), - ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null, + translations: this.getInlineMenuTranslations(), + ciphers: isInlineMenuListPort ? await this.getInlineMenuCipherData() : null, + portKey: this.portKeyForTab[port.sender.tab.id], + portName: isInlineMenuListPort + ? AutofillOverlayPort.ListMessageConnector + : AutofillOverlayPort.ButtonMessageConnector, }); - this.updateOverlayPosition( + void this.updateInlineMenuPosition( { - overlayElement: isOverlayListPort + overlayElement: isInlineMenuListPort ? AutofillOverlayElement.List : AutofillOverlayElement.Button, }, @@ -742,14 +1465,14 @@ class OverlayBackground implements OverlayBackgroundInterface { | */ private storeOverlayPort(port: chrome.runtime.Port) { if (port.name === AutofillOverlayPort.List) { - this.storeExpiredOverlayPort(this.overlayListPort); - this.overlayListPort = port; + this.storeExpiredOverlayPort(this.inlineMenuListPort); + this.inlineMenuListPort = port; return; } if (port.name === AutofillOverlayPort.Button) { - this.storeExpiredOverlayPort(this.overlayButtonPort); - this.overlayButtonPort = port; + this.storeExpiredOverlayPort(this.inlineMenuButtonPort); + this.inlineMenuButtonPort = port; } } @@ -776,15 +1499,20 @@ class OverlayBackground implements OverlayBackgroundInterface { message: OverlayBackgroundExtensionMessage, port: chrome.runtime.Port, ) => { - const command = message?.command; - let handler: CallableFunction | undefined; - - if (port.name === AutofillOverlayPort.Button) { - handler = this.overlayButtonPortMessageHandlers[command]; + const tabPortKey = this.portKeyForTab[port.sender.tab.id]; + if (!tabPortKey || tabPortKey !== message?.portKey) { + return; } - if (port.name === AutofillOverlayPort.List) { - handler = this.overlayListPortMessageHandlers[command]; + const command = message.command; + let handler: CallableFunction | undefined; + + if (port.name === AutofillOverlayPort.ButtonMessageConnector) { + handler = this.inlineMenuButtonPortMessageHandlers[command]; + } + + if (port.name === AutofillOverlayPort.ListMessageConnector) { + handler = this.inlineMenuListPortMessageHandlers[command]; } if (!handler) { @@ -793,6 +1521,22 @@ class OverlayBackground implements OverlayBackgroundInterface { handler({ message, port }); }; -} -export default OverlayBackground; + /** + * Ensures that the inline menu list and button port + * references are reset when they are disconnected. + * + * @param port - The port that was disconnected + */ + private handlePortOnDisconnect = (port: chrome.runtime.Port) => { + if (port.name === AutofillOverlayPort.List) { + this.inlineMenuListPort = null; + this.isInlineMenuListVisible = false; + } + + if (port.name === AutofillOverlayPort.Button) { + this.inlineMenuButtonPort = null; + this.isInlineMenuButtonVisible = false; + } + }; +} diff --git a/apps/browser/src/autofill/background/tabs.background.spec.ts b/apps/browser/src/autofill/background/tabs.background.spec.ts index b95e303f17..4473eb452f 100644 --- a/apps/browser/src/autofill/background/tabs.background.spec.ts +++ b/apps/browser/src/autofill/background/tabs.background.spec.ts @@ -11,7 +11,7 @@ import { } from "../spec/testing-utils"; import NotificationBackground from "./notification.background"; -import OverlayBackground from "./overlay.background"; +import { OverlayBackground } from "./overlay.background"; import TabsBackground from "./tabs.background"; describe("TabsBackground", () => { @@ -146,6 +146,7 @@ describe("TabsBackground", () => { beforeEach(() => { mainBackground.onUpdatedRan = false; + mainBackground.configService.getFeatureFlag = jest.fn().mockResolvedValue(true); tabsBackground["focusedWindowId"] = focusedWindowId; tab = mock({ windowId: focusedWindowId, @@ -154,18 +155,6 @@ describe("TabsBackground", () => { }); }); - 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); diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index 53c801ff7b..f68ae6c6ed 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -1,7 +1,9 @@ +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import MainBackground from "../../background/main.background"; +import { OverlayBackground } from "./abstractions/overlay.background"; import NotificationBackground from "./notification.background"; -import OverlayBackground from "./overlay.background"; export default class TabsBackground { constructor( @@ -86,8 +88,11 @@ export default class TabsBackground { changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab, ) => { + const overlayImprovementsFlag = await this.main.configService.getFeatureFlag( + FeatureFlag.InlineMenuPositioningImprovements, + ); const removePageDetailsStatus = new Set(["loading", "unloaded"]); - if (removePageDetailsStatus.has(changeInfo.status)) { + if (!!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) { this.overlayBackground.removePageDetails(tabId); } diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts index 91866ffa0b..8b00b4ecc9 100644 --- a/apps/browser/src/autofill/content/abstractions/autofill-init.ts +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -1,46 +1,40 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AutofillOverlayElementType } from "../../enums/autofill-overlay.enum"; import AutofillScript from "../../models/autofill-script"; -type AutofillExtensionMessage = { +export type AutofillExtensionMessage = { command: string; tab?: chrome.tabs.Tab; sender?: string; fillScript?: AutofillScript; url?: string; + subFrameUrl?: string; + subFrameId?: string; pageDetailsUrl?: string; ciphers?: any; + isInlineMenuHidden?: boolean; + overlayElement?: AutofillOverlayElementType; + isFocusingFieldElement?: boolean; + authStatus?: AuthenticationStatus; + isOpeningFullInlineMenu?: boolean; data?: { - authStatus?: AuthenticationStatus; - isFocusingFieldElement?: boolean; - isOverlayCiphersPopulated?: boolean; - direction?: "previous" | "next"; - isOpeningFullOverlay?: boolean; - forceCloseOverlay?: boolean; - autofillOverlayVisibility?: number; + direction?: "previous" | "next" | "current"; + forceCloseInlineMenu?: boolean; + inlineMenuVisibility?: number; }; }; -type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; +export type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; -type AutofillExtensionMessageHandlers = { +export type AutofillExtensionMessageHandlers = { [key: string]: CallableFunction; collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void; collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void; fillForm: ({ message }: AutofillExtensionMessageParam) => void; - openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; - closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; - addNewVaultItemFromOverlay: () => void; - redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void; - updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; - bgUnlockPopoutOpened: () => void; - bgVaultItemRepromptPopoutOpened: () => void; - updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void; }; -interface AutofillInit { +export interface AutofillInit { init(): void; destroy(): void; } - -export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit }; diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index 302b520e33..e27e8ef73d 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -1,26 +1,25 @@ -import { mock } from "jest-mock-extended"; - -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { mock, MockProxy } from "jest-mock-extended"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; -import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import { flushPromises, mockQuerySelectorAllDefinedCall, sendMockExtensionMessage, } from "../spec/testing-utils"; -import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { AutofillExtensionMessage } from "./abstractions/autofill-init"; import AutofillInit from "./autofill-init"; describe("AutofillInit", () => { + let inlineMenuElements: MockProxy; + let autofillOverlayContentService: MockProxy; let autofillInit: AutofillInit; - const autofillOverlayContentService = mock(); const originalDocumentReadyState = document.readyState; const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); + let sendExtensionMessageSpy: jest.SpyInstance; beforeEach(() => { chrome.runtime.connect = jest.fn().mockReturnValue({ @@ -28,7 +27,12 @@ describe("AutofillInit", () => { addListener: jest.fn(), }, }); - autofillInit = new AutofillInit(autofillOverlayContentService); + inlineMenuElements = mock(); + autofillOverlayContentService = mock(); + autofillInit = new AutofillInit(autofillOverlayContentService, inlineMenuElements); + sendExtensionMessageSpy = jest + .spyOn(autofillInit as any, "sendExtensionMessage") + .mockImplementation(); window.IntersectionObserver = jest.fn(() => mock()); }); @@ -61,13 +65,9 @@ describe("AutofillInit", () => { autofillInit.init(); jest.advanceTimersByTime(250); - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( - { - command: "bgCollectPageDetails", - sender: "autofillInit", - }, - expect.any(Function), - ); + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("bgCollectPageDetails", { + sender: "autofillInit", + }); }); it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { @@ -106,15 +106,15 @@ describe("AutofillInit", () => { sender = mock(); }); - it("returns a undefined value if a extension message handler is not found with the given message command", () => { + it("returns a null value if a extension message handler is not found with the given message command", () => { message.command = "unknownCommand"; const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); - expect(response).toBe(undefined); + expect(response).toBe(null); }); - it("returns a undefined value if the message handler does not return a response", async () => { + it("returns a null value if the message handler does not return a response", async () => { const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); await flushPromises(); @@ -126,7 +126,7 @@ describe("AutofillInit", () => { const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); await flushPromises(); - expect(response2).toBe(undefined); + expect(response2).toBe(null); }); it("returns a true value and calls sendResponse if the message handler returns a response", async () => { @@ -155,6 +155,22 @@ describe("AutofillInit", () => { autofillInit.init(); }); + it("triggers extension message handlers from the AutofillOverlayContentService", () => { + autofillOverlayContentService.messageHandlers.messageHandler = jest.fn(); + + sendMockExtensionMessage({ command: "messageHandler" }, sender, sendResponse); + + expect(autofillOverlayContentService.messageHandlers.messageHandler).toHaveBeenCalled(); + }); + + it("triggers extension message handlers from the AutofillInlineMenuContentService", () => { + inlineMenuElements.messageHandlers.messageHandler = jest.fn(); + + sendMockExtensionMessage({ command: "messageHandler" }, sender, sendResponse); + + expect(inlineMenuElements.messageHandlers.messageHandler).toHaveBeenCalled(); + }); + describe("collectPageDetails", () => { it("sends the collected page details for autofill using a background script message", async () => { const pageDetails: AutofillPageDetails = { @@ -177,8 +193,7 @@ describe("AutofillInit", () => { sendMockExtensionMessage(message, sender, sendResponse); await flushPromises(); - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ - command: "collectPageDetailsResponse", + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("collectPageDetailsResponse", { tab: message.tab, details: pageDetails, sender: message.sender, @@ -226,14 +241,11 @@ describe("AutofillInit", () => { }); 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(); - const message = { + sendMockExtensionMessage({ command: "fillForm", fillScript, pageDetailsUrl: "https://a-different-url.com", - }; - - sendMockExtensionMessage(message); + }); await flushPromises(); expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( @@ -255,7 +267,10 @@ describe("AutofillInit", () => { }); it("removes the overlay when filling the form", async () => { - const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); + const blurAndRemoveOverlaySpy = jest.spyOn( + autofillInit as any, + "blurFocusedFieldAndCloseInlineMenu", + ); sendMockExtensionMessage({ command: "fillForm", fillScript, @@ -268,10 +283,6 @@ describe("AutofillInit", () => { it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { jest.useFakeTimers(); - jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); - jest - .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") - .mockImplementation(); sendMockExtensionMessage({ command: "fillForm", @@ -281,292 +292,18 @@ describe("AutofillInit", () => { await flushPromises(); jest.advanceTimersByTime(300); - expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); + expect(sendExtensionMessageSpy).toHaveBeenNthCalledWith( + 1, + "updateIsFieldCurrentlyFilling", + { isFieldCurrentlyFilling: true }, + ); expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( fillScript, ); - expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); - }); - - 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(); - - sendMockExtensionMessage({ - 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( + expect(sendExtensionMessageSpy).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(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("opens the autofill overlay", () => { - sendMockExtensionMessage(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("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ - command: "closeAutofillOverlay", - data: { forceCloseOverlay: false }, - }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("removes the autofill overlay if the message flags a forced closure", () => { - sendMockExtensionMessage({ - command: "closeAutofillOverlay", - data: { forceCloseOverlay: true }, - }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).toHaveBeenCalled(); - }); - - it("ignores the message if a field is currently focused", () => { - autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; - - sendMockExtensionMessage({ 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; - - sendMockExtensionMessage({ 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", () => { - sendMockExtensionMessage({ 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(); - - sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("will add a new vault item", () => { - sendMockExtensionMessage({ 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(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("redirects the overlay focus", () => { - sendMockExtensionMessage(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(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("updates whether the overlay ciphers are populated", () => { - sendMockExtensionMessage(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"); - - sendMockExtensionMessage({ 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"); - - sendMockExtensionMessage({ 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"); - - sendMockExtensionMessage({ 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"); - - sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); - - expect( - autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, - ).toHaveBeenCalled(); - expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); - }); - }); - - describe("updateAutofillOverlayVisibility", () => { - beforeEach(() => { - autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = - AutofillOverlayVisibility.OnButtonClick; - }); - - it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { - sendMockExtensionMessage({ - command: "updateAutofillOverlayVisibility", - data: {}, - }); - - expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( - AutofillOverlayVisibility.OnButtonClick, - ); - }); - - it("updates the overlay visibility value", () => { - const message = { - command: "updateAutofillOverlayVisibility", - data: { - autofillOverlayVisibility: AutofillOverlayVisibility.Off, - }, - }; - - sendMockExtensionMessage(message); - - expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( - message.data.autofillOverlayVisibility, + "updateIsFieldCurrentlyFilling", + { isFieldCurrentlyFilling: false }, ); }); }); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index e78a1fb5ee..70f815d223 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -1,4 +1,7 @@ +import { EVENTS } from "@bitwarden/common/autofill/constants"; + import AutofillPageDetails from "../models/autofill-page-details"; +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; 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"; @@ -12,7 +15,9 @@ import { } from "./abstractions/autofill-init"; class AutofillInit implements AutofillInitInterface { + private readonly sendExtensionMessage = sendExtensionMessage; private readonly autofillOverlayContentService: AutofillOverlayContentService | undefined; + private readonly autofillInlineMenuContentService: AutofillInlineMenuContentService | undefined; private readonly domElementVisibilityService: DomElementVisibilityService; private readonly collectAutofillContentService: CollectAutofillContentService; private readonly insertAutofillContentService: InsertAutofillContentService; @@ -21,14 +26,6 @@ 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: ({ message }) => this.removeAutofillOverlay(message), - addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(), - redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message), - updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), - bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), - bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), - updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message), }; /** @@ -36,10 +33,17 @@ class AutofillInit implements AutofillInitInterface { * CollectAutofillContentService and InsertAutofillContentService classes. * * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. + * @param inlineMenuElements - The inline menu elements, potentially undefined. */ - constructor(autofillOverlayContentService?: AutofillOverlayContentService) { + constructor( + autofillOverlayContentService?: AutofillOverlayContentService, + inlineMenuElements?: AutofillInlineMenuContentService, + ) { this.autofillOverlayContentService = autofillOverlayContentService; - this.domElementVisibilityService = new DomElementVisibilityService(); + this.autofillInlineMenuContentService = inlineMenuElements; + this.domElementVisibilityService = new DomElementVisibilityService( + this.autofillInlineMenuContentService, + ); this.collectAutofillContentService = new CollectAutofillContentService( this.domElementVisibilityService, this.autofillOverlayContentService, @@ -70,7 +74,7 @@ class AutofillInit implements AutofillInitInterface { const sendCollectDetailsMessage = () => { this.clearCollectPageDetailsOnLoadTimeout(); this.collectPageDetailsOnLoadTimeout = setTimeout( - () => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), + () => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), 250, ); }; @@ -79,7 +83,7 @@ class AutofillInit implements AutofillInitInterface { sendCollectDetailsMessage(); } - globalThis.addEventListener("load", sendCollectDetailsMessage); + globalThis.addEventListener(EVENTS.LOAD, sendCollectDetailsMessage); } /** @@ -102,8 +106,7 @@ class AutofillInit implements AutofillInitInterface { return pageDetails; } - void chrome.runtime.sendMessage({ - command: "collectPageDetailsResponse", + void this.sendExtensionMessage("collectPageDetailsResponse", { tab: message.tab, details: pageDetails, sender: message.sender, @@ -120,134 +123,28 @@ class AutofillInit implements AutofillInitInterface { return; } - this.blurAndRemoveOverlay(); - this.updateOverlayIsCurrentlyFilling(true); + this.blurFocusedFieldAndCloseInlineMenu(); + await this.sendExtensionMessage("updateIsFieldCurrentlyFilling", { + isFieldCurrentlyFilling: true, + }); await this.insertAutofillContentService.fillForm(fillScript); - if (!this.autofillOverlayContentService) { - return; - } - - setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250); - } - - /** - * 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(message?: AutofillExtensionMessage) { - if (message?.data?.forceCloseOverlay) { - this.autofillOverlayContentService?.removeAutofillOverlay(); - return; - } - - 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, + setTimeout( + () => + this.sendExtensionMessage("updateIsFieldCurrentlyFilling", { + isFieldCurrentlyFilling: false, + }), + 250, ); } /** - * Updates the autofill overlay visibility. - * - * @param data - Contains the autoFillOverlayVisibility value + * Blurs the most recently focused field and removes the inline menu. Used + * in cases where the background unlock or vault item reprompt popout + * is opened. */ - private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) { - return; - } - - this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility; + private blurFocusedFieldAndCloseInlineMenu() { + this.autofillOverlayContentService?.blurMostRecentlyFocusedField(true); } /** @@ -279,22 +176,37 @@ class AutofillInit implements AutofillInitInterface { sendResponse: (response?: any) => void, ): boolean => { const command: string = message.command; - const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; + const handler: CallableFunction | undefined = this.getExtensionMessageHandler(command); if (!handler) { - return; + return null; } const messageResponse = handler({ message, sender }); - if (!messageResponse) { - return; + if (typeof messageResponse === "undefined") { + return null; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve(messageResponse).then((response) => sendResponse(response)); + void Promise.resolve(messageResponse).then((response) => sendResponse(response)); return true; }; + /** + * Gets the extension message handler for the given command. + * + * @param command - The extension message command. + */ + private getExtensionMessageHandler(command: string): CallableFunction | undefined { + if (this.autofillOverlayContentService?.messageHandlers?.[command]) { + return this.autofillOverlayContentService.messageHandlers[command]; + } + + if (this.autofillInlineMenuContentService?.messageHandlers?.[command]) { + return this.autofillInlineMenuContentService.messageHandlers[command]; + } + + return this.extensionMessageHandlers[command]; + } + /** * Handles destroying the autofill init content script. Removes all * listeners, timeouts, and object instances to prevent memory leaks. @@ -304,6 +216,7 @@ class AutofillInit implements AutofillInitInterface { chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); this.collectAutofillContentService.destroy(); this.autofillOverlayContentService?.destroy(); + this.autofillInlineMenuContentService?.destroy(); } } diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index ab21e367c2..2243022766 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,12 +1,24 @@ -import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { - const autofillOverlayContentService = new AutofillOverlayContentService(); - windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + inlineMenuFieldQualificationService, + ); + let inlineMenuElements: AutofillInlineMenuContentService; + if (globalThis.self === globalThis.top) { + inlineMenuElements = new AutofillInlineMenuContentService(); + } + windowContext.bitwardenAutofillInit = new AutofillInit( + autofillOverlayContentService, + inlineMenuElements, + ); setupAutofillInitDisconnectAction(windowContext); windowContext.bitwardenAutofillInit.init(); diff --git a/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts new file mode 100644 index 0000000000..88b78dc249 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts @@ -0,0 +1,124 @@ +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; + +import { LockedVaultPendingNotificationsData } from "../../../background/abstractions/notification.background"; +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?: LockedVaultPendingNotificationsData; +} & OverlayAddNewItemMessage; + +type OverlayPortMessage = { + [key: string]: any; + command: string; + direction?: string; + overlayCipherId?: string; +}; + +type FocusedFieldData = { + focusedFieldStyles: Partial; + focusedFieldRects: Partial; + tabId?: number; +}; + +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, sender }: BackgroundOnMessageHandlerParams) => void; + autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + getAutofillOverlayVisibility: () => void; + checkAutofillOverlayFocused: () => void; + focusAutofillOverlayList: () => void; + updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void; + updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + unlockCompleted: ({ message }: BackgroundMessageParam) => void; + addedCipher: () => void; + addEditCipherSubmitted: () => void; + editedCipher: () => 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; + forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; + overlayPageBlurred: () => void; + redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; +}; + +type OverlayListPortMessageHandlers = { + [key: string]: CallableFunction; + checkAutofillOverlayButtonFocused: () => void; + forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => 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; +}; + +export { + WebsiteIconData, + OverlayBackgroundExtensionMessage, + OverlayPortMessage, + FocusedFieldData, + OverlayCipherData, + OverlayAddNewItemMessage, + OverlayBackgroundExtensionMessageHandlers, + OverlayButtonPortMessageHandlers, + OverlayListPortMessageHandlers, +}; diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts new file mode 100644 index 0000000000..c3285059c7 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts @@ -0,0 +1,1463 @@ +import { mock, MockProxy, mockReset } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { + SHOW_AUTOFILL_BUTTON, + AutofillOverlayVisibility, +} from "@bitwarden/common/autofill/constants"; +import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { + DefaultDomainSettingsService, + DomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { + FakeStateProvider, + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; +import { + AutofillOverlayElement, + AutofillOverlayPort, + RedirectFocusDirection, +} from "../../enums/autofill-overlay.enum"; +import { AutofillService } from "../../services/abstractions/autofill.service"; +import { + createAutofillPageDetailsMock, + createChromeTabMock, + createFocusedFieldDataMock, + createPageDetailMock, + createPortSpyMock, +} from "../../spec/autofill-mocks"; +import { flushPromises, sendMockExtensionMessage, sendPortMessage } from "../../spec/testing-utils"; + +import LegacyOverlayBackground from "./overlay.background.deprecated"; + +describe("OverlayBackground", () => { + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + let domainSettingsService: DomainSettingsService; + let buttonPortSpy: chrome.runtime.Port; + let listPortSpy: chrome.runtime.Port; + let overlayBackground: LegacyOverlayBackground; + const cipherService = mock(); + const autofillService = mock(); + let activeAccountStatusMock$: BehaviorSubject; + let authService: MockProxy; + + const environmentService = mock(); + environmentService.environment$ = new BehaviorSubject( + new CloudEnvironment({ + key: Region.US, + domain: "bitwarden.com", + urls: { icons: "https://icons.bitwarden.com/" }, + }), + ); + const autofillSettingsService = mock(); + const i18nService = mock(); + const platformUtilsService = mock(); + const themeStateService = mock(); + const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { + const { initList, initButton } = options; + if (initButton) { + await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["overlayButtonPort"]; + } + + if (initList) { + await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["overlayListPort"]; + } + + return { buttonPortSpy, listPortSpy }; + }; + + beforeEach(() => { + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); + authService = mock(); + authService.activeAccountStatus$ = activeAccountStatusMock$; + overlayBackground = new LegacyOverlayBackground( + cipherService, + autofillService, + authService, + environmentService, + domainSettingsService, + autofillSettingsService, + i18nService, + platformUtilsService, + themeStateService, + ); + + jest + .spyOn(overlayBackground as any, "getOverlayVisibility") + .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + + themeStateService.selectedTheme$ = of(ThemeType.Light); + domainSettingsService.showFavicons$ = of(true); + + void overlayBackground.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockReset(cipherService); + }); + + describe("removePageDetails", () => { + it("removes the page details for a specific tab from the pageDetailsForTab object", () => { + const tabId = 1; + const frameId = 2; + overlayBackground["pageDetailsForTab"][tabId] = new Map([[frameId, createPageDetailMock()]]); + overlayBackground.removePageDetails(tabId); + + expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); + }); + }); + + describe("init", () => { + it("sets up the extension message listeners, get the overlay's visibility settings, and get the user's auth status", async () => { + overlayBackground["setupExtensionMessageListeners"] = jest.fn(); + overlayBackground["getOverlayVisibility"] = jest.fn(); + overlayBackground["getAuthStatus"] = jest.fn(); + + await overlayBackground.init(); + + expect(overlayBackground["setupExtensionMessageListeners"]).toHaveBeenCalled(); + expect(overlayBackground["getOverlayVisibility"]).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + }); + }); + + describe("updateOverlayCiphers", () => { + const url = "https://jest-testing-website.com"; + const tab = createChromeTabMock({ url }); + const cipher1 = mock({ + id: "id-1", + localData: { lastUsedDate: 222 }, + name: "name-1", + type: CipherType.Login, + login: { username: "username-1", uri: url }, + }); + const cipher2 = mock({ + id: "id-2", + localData: { lastUsedDate: 111 }, + name: "name-2", + type: CipherType.Login, + login: { username: "username-2", uri: url }, + }); + + beforeEach(() => { + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + }); + + it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); + jest.spyOn(cipherService, "getAllDecryptedForUrl"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).not.toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + }); + + it("ignores updating the overlay ciphers if the tab is undefined", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(undefined); + jest.spyOn(cipherService, "getAllDecryptedForUrl"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + }); + + it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + jest.spyOn(overlayBackground as any, "getOverlayCipherData"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); + expect(overlayBackground["cipherService"].sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( + new Map([ + ["overlay-cipher-0", cipher2], + ["overlay-cipher-1", cipher1], + ]), + ); + expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); + }); + + it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateIsOverlayCiphersPopulated` message to the tab indicating that the list of ciphers is populated", async () => { + overlayBackground["overlayListPort"] = mock(); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + await overlayBackground.updateOverlayCiphers(); + + expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayListCiphers", + ciphers: [ + { + card: null, + favorite: cipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-0", + login: { + username: "username-2", + }, + name: "name-2", + reprompt: cipher2.reprompt, + type: 1, + }, + { + card: null, + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-1", + login: { + username: "username-1", + }, + name: "name-1", + reprompt: cipher1.reprompt, + type: 1, + }, + ], + }); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + tab, + "updateIsOverlayCiphersPopulated", + { isOverlayCiphersPopulated: true }, + ); + }); + }); + + describe("getOverlayCipherData", () => { + const url = "https://jest-testing-website.com"; + const cipher1 = mock({ + id: "id-1", + localData: { lastUsedDate: 222 }, + name: "name-1", + type: CipherType.Login, + login: { username: "username-1", uri: url }, + }); + const cipher2 = mock({ + id: "id-2", + localData: { lastUsedDate: 111 }, + name: "name-2", + type: CipherType.Login, + login: { username: "username-2", uri: url }, + }); + const cipher3 = mock({ + id: "id-3", + localData: { lastUsedDate: 333 }, + name: "name-3", + type: CipherType.Card, + card: { subTitle: "Visa, *6789" }, + }); + const cipher4 = mock({ + id: "id-4", + localData: { lastUsedDate: 444 }, + name: "name-4", + type: CipherType.Card, + card: { subTitle: "Mastercard, *1234" }, + }); + + it("formats and returns the cipher data", async () => { + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", cipher2], + ["overlay-cipher-1", cipher1], + ["overlay-cipher-2", cipher3], + ["overlay-cipher-3", cipher4], + ]); + + const overlayCipherData = await overlayBackground["getOverlayCipherData"](); + + expect(overlayCipherData).toStrictEqual([ + { + card: null, + favorite: cipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-0", + login: { + username: "username-2", + }, + name: "name-2", + reprompt: cipher2.reprompt, + type: 1, + }, + { + card: null, + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-1", + login: { + username: "username-1", + }, + name: "name-1", + reprompt: cipher1.reprompt, + type: 1, + }, + { + card: "Visa, *6789", + favorite: cipher3.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "overlay-cipher-2", + login: null, + name: "name-3", + reprompt: cipher3.reprompt, + type: 3, + }, + { + card: "Mastercard, *1234", + favorite: cipher4.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "overlay-cipher-3", + login: null, + name: "name-4", + reprompt: cipher4.reprompt, + type: 3, + }, + ]); + }); + }); + + describe("getAuthStatus", () => { + it("will update the user's auth status but will not update the overlay ciphers", async () => { + const authStatus = AuthenticationStatus.Unlocked; + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); + jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + const status = await overlayBackground["getAuthStatus"](); + + expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayButtonAuthStatus"]).not.toHaveBeenCalled(); + expect(overlayBackground["updateOverlayCiphers"]).not.toHaveBeenCalled(); + expect(overlayBackground["userAuthStatus"]).toBe(authStatus); + expect(status).toBe(authStatus); + }); + + it("will update the user's auth status and update the overlay ciphers if the status has been modified", async () => { + const authStatus = AuthenticationStatus.Unlocked; + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); + jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + await overlayBackground["getAuthStatus"](); + + expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayButtonAuthStatus"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + expect(overlayBackground["userAuthStatus"]).toBe(authStatus); + }); + }); + + describe("updateOverlayButtonAuthStatus", () => { + it("will send a message to the button port with the user's auth status", () => { + overlayBackground["overlayButtonPort"] = mock(); + jest.spyOn(overlayBackground["overlayButtonPort"], "postMessage"); + + overlayBackground["updateOverlayButtonAuthStatus"](); + + expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayButtonAuthStatus", + authStatus: overlayBackground["userAuthStatus"], + }); + }); + }); + + describe("getTranslations", () => { + it("will query the overlay page translations if they have not been queried", () => { + overlayBackground["overlayPageTranslations"] = undefined; + jest.spyOn(overlayBackground as any, "getTranslations"); + jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key); + jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en"); + + const translations = overlayBackground["getTranslations"](); + + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + const translationKeys = [ + "opensInANewWindow", + "bitwardenOverlayButton", + "toggleBitwardenVaultOverlay", + "bitwardenVault", + "unlockYourAccountToViewMatchingLogins", + "unlockAccount", + "fillCredentialsFor", + "partialUsername", + "view", + "noItemsToShow", + "newItem", + "addNewVaultItem", + ]; + translationKeys.forEach((key) => { + expect(overlayBackground["i18nService"].translate).toHaveBeenCalledWith(key); + }); + expect(translations).toStrictEqual({ + locale: "en", + opensInANewWindow: "opensInANewWindow", + buttonPageTitle: "bitwardenOverlayButton", + toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", + listPageTitle: "bitwardenVault", + unlockYourAccount: "unlockYourAccountToViewMatchingLogins", + unlockAccount: "unlockAccount", + fillCredentialsFor: "fillCredentialsFor", + partialUsername: "partialUsername", + view: "view", + noItemsToShow: "noItemsToShow", + newItem: "newItem", + addNewVaultItem: "addNewVaultItem", + }); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("will set up onMessage and onConnect listeners", () => { + overlayBackground["setupExtensionMessageListeners"](); + + // eslint-disable-next-line + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalled(); + }); + }); + + describe("handleExtensionMessage", () => { + it("will return early if the message command is not present within the extensionMessageHandlers", () => { + const message = { + command: "not-a-command", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(null); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("will trigger the message handler and return undefined if the message does not have a response", () => { + const message = { + command: "autofillOverlayElementClosed", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + jest.spyOn(overlayBackground as any, "overlayElementClosed"); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(null); + expect(sendResponse).not.toHaveBeenCalled(); + expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message, sender); + }); + + it("will return a response if the message handler returns a response", async () => { + const message = { + command: "openAutofillOverlay", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + jest.spyOn(overlayBackground as any, "getTranslations").mockReturnValue("translations"); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(true); + }); + + describe("extension message handlers", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground as any, "getAuthStatus") + .mockResolvedValue(AuthenticationStatus.Unlocked); + }); + + describe("openAutofillOverlay message handler", () => { + it("opens the autofill overlay by sending a message to the current tab", async () => { + const sender = mock({ tab: { id: 1 } }); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + sendMockExtensionMessage({ command: "openAutofillOverlay" }); + await flushPromises(); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + sender.tab, + "openAutofillOverlay", + { + isFocusingFieldElement: false, + isOpeningFullOverlay: false, + authStatus: AuthenticationStatus.Unlocked, + }, + ); + }); + }); + + describe("autofillOverlayElementClosed message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { + const port1 = mock(); + const port2 = mock(); + overlayBackground["expiredPorts"] = [port1, port2]; + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage( + { + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + + expect(port1.disconnect).toHaveBeenCalled(); + expect(port2.disconnect).toHaveBeenCalled(); + }); + + it("disconnects the button element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.disconnect).toHaveBeenCalled(); + expect(overlayBackground["overlayButtonPort"]).toBeNull(); + }); + + it("disconnects the list element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.disconnect).toHaveBeenCalled(); + expect(overlayBackground["overlayListPort"]).toBeNull(); + }); + }); + + describe("autofillOverlayAddNewVaultItem message handler", () => { + let sender: chrome.runtime.MessageSender; + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + jest + .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") + .mockImplementation(); + jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); + }); + + it("will not open the add edit popout window if the message does not have a login cipher provided", () => { + sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); + + expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); + }); + + it("will open the add edit popout window after creating a new cipher", async () => { + jest.spyOn(BrowserApi, "sendMessage"); + + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender, + ); + await flushPromises(); + + expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); + expect(BrowserApi.sendMessage).toHaveBeenCalledWith( + "inlineAutofillMenuRefreshAddEditCipher", + ); + expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); + }); + }); + + describe("getAutofillOverlayVisibility message handler", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground as any, "getOverlayVisibility") + .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + }); + + it("will set the overlayVisibility property", async () => { + sendMockExtensionMessage({ command: "getAutofillOverlayVisibility" }); + await flushPromises(); + + expect(await overlayBackground["getOverlayVisibility"]()).toBe( + AutofillOverlayVisibility.OnFieldFocus, + ); + }); + + it("returns the overlayVisibility property", async () => { + const sendMessageSpy = jest.fn(); + + sendMockExtensionMessage( + { command: "getAutofillOverlayVisibility" }, + undefined, + sendMessageSpy, + ); + await flushPromises(); + + expect(sendMessageSpy).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); + }); + }); + + describe("checkAutofillOverlayFocused message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("will check if the overlay list is focused if the list port is open", () => { + sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillOverlayListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillOverlayButtonFocused", + }); + }); + + it("will check if the overlay button is focused if the list port is not open", () => { + overlayBackground["overlayListPort"] = undefined; + + sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillOverlayButtonFocused", + }); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillOverlayListFocused", + }); + }); + }); + + describe("focusAutofillOverlayList message handler", () => { + it("will send a `focusOverlayList` message to the overlay list port", async () => { + await initOverlayElementPorts({ initList: true, initButton: false }); + + sendMockExtensionMessage({ command: "focusAutofillOverlayList" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "focusOverlayList" }); + }); + }); + + describe("updateAutofillOverlayPosition message handler", () => { + beforeEach(async () => { + await overlayBackground["handlePortOnConnect"]( + createPortSpyMock(AutofillOverlayPort.List), + ); + listPortSpy = overlayBackground["overlayListPort"]; + + await overlayBackground["handlePortOnConnect"]( + createPortSpyMock(AutofillOverlayPort.Button), + ); + buttonPortSpy = overlayBackground["overlayButtonPort"]; + }); + + it("ignores updating the position if the overlay element type is not provided", () => { + sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("skips updating the position if the most recently focused field is different than the message sender", () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }, sender); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("updates the overlay button's position", () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, + }); + }); + + it("modifies the overlay button's height for medium sized input elements", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, + }); + }); + + it("modifies the overlay button's height for large sized input elements", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, + }); + }); + + it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, + }); + }); + + it("will post a message to the overlay list facilitating an update of the list's position", () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + overlayBackground["updateOverlayPosition"]( + { overlayElement: AutofillOverlayElement.List }, + sender, + ); + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { left: "2px", top: "4px", width: "4px" }, + }); + }); + }); + + describe("updateOverlayHidden", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("returns early if the display value is not provided", () => { + const message = { + command: "updateAutofillOverlayHidden", + }; + + sendMockExtensionMessage(message); + + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); + }); + + it("posts a message to the overlay button and list with the display value", () => { + const message = { command: "updateAutofillOverlayHidden", display: "none" }; + + sendMockExtensionMessage(message); + + expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayHidden", + styles: { + display: message.display, + }, + }); + expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayHidden", + styles: { + display: message.display, + }, + }); + }); + }); + + describe("collectPageDetailsResponse message handler", () => { + let sender: chrome.runtime.MessageSender; + const pageDetails1 = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + const pageDetails2 = createAutofillPageDetailsMock({ + login: { username: "username2", password: "password2" }, + }); + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + }); + + it("stores the page details provided by the message by the tab id of the sender", () => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails1 }, + sender, + ); + + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( + new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + ]), + ); + }); + + it("updates the page details for a tab that already has a set of page details stored ", () => { + const secondFrameSender = mock({ + tab: { id: 1 }, + frameId: 3, + }); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + ]); + + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails2 }, + secondFrameSender, + ); + + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( + new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + [ + secondFrameSender.frameId, + { + frameId: secondFrameSender.frameId, + tab: secondFrameSender.tab, + details: pageDetails2, + }, + ], + ]), + ); + }); + }); + + describe("unlockCompleted message handler", () => { + let getAuthStatusSpy: jest.SpyInstance; + + beforeEach(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(BrowserApi, "tabSendMessageData"); + getAuthStatusSpy = jest + .spyOn(overlayBackground as any, "getAuthStatus") + .mockImplementation(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + return Promise.resolve(AuthenticationStatus.Unlocked); + }); + }); + + it("updates the user's auth status but does not open the overlay", async () => { + const message = { + command: "unlockCompleted", + data: { + commandToRetry: { message: { command: "" } }, + }, + }; + + sendMockExtensionMessage(message); + await flushPromises(); + + expect(getAuthStatusSpy).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); + }); + + it("updates user's auth status and opens the overlay if a follow up command is provided", async () => { + const sender = mock({ tab: { id: 1 } }); + const message = { + command: "unlockCompleted", + data: { + commandToRetry: { message: { command: "openAutofillOverlay" } }, + }, + }; + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); + + sendMockExtensionMessage(message); + await flushPromises(); + + expect(getAuthStatusSpy).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + sender.tab, + "openAutofillOverlay", + { + isFocusingFieldElement: true, + isOpeningFullOverlay: false, + authStatus: AuthenticationStatus.Unlocked, + }, + ); + }); + }); + + describe("extension messages that trigger an update of the inline menu ciphers", () => { + const extensionMessages = [ + "addedCipher", + "addEditCipherSubmitted", + "editedCipher", + "deletedCipher", + ]; + + beforeEach(() => { + jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); + }); + + extensionMessages.forEach((message) => { + it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { + sendMockExtensionMessage({ command: message }); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe("handlePortOnConnect", () => { + beforeEach(() => { + jest.spyOn(overlayBackground as any, "updateOverlayPosition").mockImplementation(); + jest.spyOn(overlayBackground as any, "getAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "getTranslations").mockImplementation(); + jest.spyOn(overlayBackground as any, "getOverlayCipherData").mockImplementation(); + }); + + it("skips setting up the overlay port if the port connection is not for an overlay element", async () => { + const port = createPortSpyMock("not-an-overlay-element"); + + await overlayBackground["handlePortOnConnect"](port); + + expect(port.onMessage.addListener).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("sets up the overlay list port if the port connection is for the overlay list", async () => { + await initOverlayElementPorts({ initList: true, initButton: false }); + await flushPromises(); + + expect(overlayBackground["overlayButtonPort"]).toBeUndefined(); + expect(listPortSpy.onMessage.addListener).toHaveBeenCalled(); + expect(listPortSpy.postMessage).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css"); + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( + { overlayElement: AutofillOverlayElement.List }, + listPortSpy.sender, + ); + }); + + it("sets up the overlay button port if the port connection is for the overlay button", async () => { + await initOverlayElementPorts({ initList: false, initButton: true }); + await flushPromises(); + + expect(overlayBackground["overlayListPort"]).toBeUndefined(); + expect(buttonPortSpy.onMessage.addListener).toHaveBeenCalled(); + expect(buttonPortSpy.postMessage).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css"); + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( + { overlayElement: AutofillOverlayElement.Button }, + buttonPortSpy.sender, + ); + }); + + it("stores an existing overlay port so that it can be disconnected at a later time", async () => { + overlayBackground["overlayButtonPort"] = mock(); + + await initOverlayElementPorts({ initList: false, initButton: true }); + await flushPromises(); + + expect(overlayBackground["expiredPorts"].length).toBe(1); + }); + + it("gets the system theme", async () => { + themeStateService.selectedTheme$ = of(ThemeType.System); + + await initOverlayElementPorts({ initList: true, initButton: false }); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ theme: ThemeType.System }), + ); + }); + }); + + describe("handleOverlayElementPortMessage", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + }); + + it("ignores port messages that do not contain a handler", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "checkAutofillOverlayButtonFocused" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).not.toHaveBeenCalled(); + }); + + describe("overlay button message handlers", () => { + it("unlocks the vault if the user auth status is not unlocked", () => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(overlayBackground as any, "unlockVault").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); + + expect(overlayBackground["unlockVault"]).toHaveBeenCalled(); + }); + + it("opens the autofill overlay if the auth status is unlocked", () => { + jest.spyOn(overlayBackground as any, "openOverlay").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); + + expect(overlayBackground["openOverlay"]).toHaveBeenCalled(); + }); + + describe("closeAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab", () => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + + sendPortMessage(buttonPortSpy, { command: "closeAutofillOverlay" }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "closeAutofillOverlay", + { forceCloseOverlay: false }, + ); + }); + }); + + describe("forceCloseAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + + sendPortMessage(buttonPortSpy, { command: "forceCloseAutofillOverlay" }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "closeAutofillOverlay", + { forceCloseOverlay: true }, + ); + }); + }); + + describe("overlayPageBlurred", () => { + it("checks if the overlay list is focused", () => { + jest.spyOn(overlayBackground as any, "checkOverlayListFocused"); + + sendPortMessage(buttonPortSpy, { command: "overlayPageBlurred" }); + + expect(overlayBackground["checkOverlayListFocused"]).toHaveBeenCalled(); + }); + }); + + describe("redirectOverlayFocusOut", () => { + beforeEach(() => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + }); + + it("ignores the redirect message if the direction is not provided", () => { + sendPortMessage(buttonPortSpy, { command: "redirectOverlayFocusOut" }); + + expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); + }); + + it("sends the redirect message if the direction is provided", () => { + sendPortMessage(buttonPortSpy, { + command: "redirectOverlayFocusOut", + direction: RedirectFocusDirection.Next, + }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "redirectOverlayFocusOut", + { direction: RedirectFocusDirection.Next }, + ); + }); + }); + }); + + describe("overlay list message handlers", () => { + describe("checkAutofillOverlayButtonFocused", () => { + it("checks on the focus state of the overlay button", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "checkAutofillOverlayButtonFocused" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); + }); + }); + + describe("forceCloseAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + + sendPortMessage(listPortSpy, { command: "forceCloseAutofillOverlay" }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + listPortSpy.sender.tab, + "closeAutofillOverlay", + { forceCloseOverlay: true }, + ); + }); + }); + + describe("overlayPageBlurred", () => { + it("checks on the focus state of the overlay button", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "overlayPageBlurred" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); + }); + }); + + describe("unlockVault", () => { + it("closes the autofill overlay and opens the unlock popout", async () => { + jest.spyOn(overlayBackground as any, "closeOverlay").mockImplementation(); + jest.spyOn(overlayBackground as any, "openUnlockPopout").mockImplementation(); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "unlockVault" }); + await flushPromises(); + + expect(overlayBackground["closeOverlay"]).toHaveBeenCalledWith(listPortSpy); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + listPortSpy.sender.tab, + "addToLockedVaultPendingNotifications", + { + commandToRetry: { + message: { command: "openAutofillOverlay" }, + sender: listPortSpy.sender, + }, + target: "overlay.background", + }, + ); + expect(overlayBackground["openUnlockPopout"]).toHaveBeenCalledWith( + listPortSpy.sender.tab, + true, + ); + }); + }); + + describe("fillSelectedListItem", () => { + let getLoginCiphersSpy: jest.SpyInstance; + let isPasswordRepromptRequiredSpy: jest.SpyInstance; + let doAutoFillSpy: jest.SpyInstance; + let sender: chrome.runtime.MessageSender; + const pageDetails = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + + beforeEach(() => { + getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); + isPasswordRepromptRequiredSpy = jest.spyOn( + overlayBackground["autofillService"], + "isPasswordRepromptRequired", + ); + doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); + sender = mock({ tab: { id: 1 } }); + }); + + it("ignores the fill request if the overlay cipher id is not provided", async () => { + sendPortMessage(listPortSpy, { command: "fillSelectedListItem" }); + await flushPromises(); + + expect(getLoginCiphersSpy).not.toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if the tab does not contain any identified page details", async () => { + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(getLoginCiphersSpy).not.toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if a master password reprompt is required", async () => { + const cipher = mock({ + reprompt: CipherRepromptType.Password, + type: CipherType.Login, + }); + overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); + isPasswordRepromptRequiredSpy.mockResolvedValue(true); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(getLoginCiphersSpy).toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( + cipher, + listPortSpy.sender.tab, + ); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { + const cipher1 = mock({ id: "overlay-cipher-1" }); + const cipher2 = mock({ id: "overlay-cipher-2" }); + const cipher3 = mock({ id: "overlay-cipher-3" }); + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-1", cipher1], + ["overlay-cipher-2", cipher2], + ["overlay-cipher-3", cipher3], + ]); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + isPasswordRepromptRequiredSpy.mockResolvedValue(false); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-2", + }); + await flushPromises(); + + expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( + cipher2, + listPortSpy.sender.tab, + ); + expect(doAutoFillSpy).toHaveBeenCalledWith({ + tab: listPortSpy.sender.tab, + cipher: cipher2, + pageDetails: [pageDetailsForTab], + fillNewPassword: true, + allowTotpAutofill: true, + }); + expect(overlayBackground["overlayLoginCiphers"].entries()).toStrictEqual( + new Map([ + ["overlay-cipher-2", cipher2], + ["overlay-cipher-1", cipher1], + ["overlay-cipher-3", cipher3], + ]).entries(), + ); + }); + + it("copies the cipher's totp code to the clipboard after filling", async () => { + const cipher1 = mock({ id: "overlay-cipher-1" }); + overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + isPasswordRepromptRequiredSpy.mockResolvedValue(false); + const copyToClipboardSpy = jest + .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") + .mockImplementation(); + doAutoFillSpy.mockReturnValueOnce("totp-code"); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-2", + }); + await flushPromises(); + + expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); + }); + }); + + describe("getNewVaultItemDetails", () => { + it("will send an addNewVaultItemFromOverlay message", async () => { + jest.spyOn(BrowserApi, "tabSendMessage"); + + sendPortMessage(listPortSpy, { command: "addNewVaultItem" }); + await flushPromises(); + + expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(listPortSpy.sender.tab, { + command: "addNewVaultItemFromOverlay", + }); + }); + }); + + describe("viewSelectedCipher", () => { + let openViewVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + openViewVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openViewVaultItemPopout") + .mockImplementation(); + }); + + it("returns early if the passed cipher ID does not match one of the overlay login ciphers", async () => { + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ]); + + sendPortMessage(listPortSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the view vault item popout with the selected cipher", async () => { + const cipher = mock({ id: "overlay-cipher-1" }); + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ["overlay-cipher-1", cipher], + ]); + + sendPortMessage(listPortSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(overlayBackground["openViewVaultItemPopout"]).toHaveBeenCalledWith( + listPortSpy.sender.tab, + { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }, + ); + }); + }); + + describe("redirectOverlayFocusOut", () => { + it("redirects focus out of the overlay list", async () => { + const message = { + command: "redirectOverlayFocusOut", + direction: RedirectFocusDirection.Next, + }; + const redirectOverlayFocusOutSpy = jest.spyOn( + overlayBackground as any, + "redirectOverlayFocusOut", + ); + + sendPortMessage(listPortSpy, message); + await flushPromises(); + + expect(redirectOverlayFocusOutSpy).toHaveBeenCalledWith(message, listPortSpy); + }); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts new file mode 100644 index 0000000000..1a5d49e9e1 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts @@ -0,0 +1,798 @@ +import { firstValueFrom } from "rxjs"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +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 { BrowserApi } from "../../../platform/browser/browser-api"; +import { + openViewVaultItemPopout, + openAddEditVaultItemPopout, +} from "../../../vault/popup/utils/vault-popout-window"; +import { LockedVaultPendingNotificationsData } from "../../background/abstractions/notification.background"; +import { OverlayBackground as OverlayBackgroundInterface } from "../../background/abstractions/overlay.background"; +import { AutofillOverlayElement, AutofillOverlayPort } from "../../enums/autofill-overlay.enum"; +import { AutofillService, PageDetail } from "../../services/abstractions/autofill.service"; + +import { + FocusedFieldData, + OverlayBackgroundExtensionMessageHandlers, + OverlayButtonPortMessageHandlers, + OverlayCipherData, + OverlayListPortMessageHandlers, + OverlayBackgroundExtensionMessage, + OverlayAddNewItemMessage, + OverlayPortMessage, + WebsiteIconData, +} from "./abstractions/overlay.background.deprecated"; + +class LegacyOverlayBackground implements OverlayBackgroundInterface { + private readonly openUnlockPopout = openUnlockPopout; + private readonly openViewVaultItemPopout = openViewVaultItemPopout; + private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; + private overlayLoginCiphers: Map = new Map(); + private pageDetailsForTab: Record< + chrome.runtime.MessageSender["tab"]["id"], + Map + > = {}; + private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; + private overlayButtonPort: chrome.runtime.Port; + private overlayListPort: chrome.runtime.Port; + private expiredPorts: chrome.runtime.Port[] = []; + private focusedFieldData: FocusedFieldData; + private overlayPageTranslations: Record; + private iconsServerUrl: string; + private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { + openAutofillOverlay: () => this.openOverlay(false), + autofillOverlayElementClosed: ({ message, sender }) => + this.overlayElementClosed(message, sender), + autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), + getAutofillOverlayVisibility: () => this.getOverlayVisibility(), + checkAutofillOverlayFocused: () => this.checkOverlayFocused(), + focusAutofillOverlayList: () => this.focusOverlayList(), + updateAutofillOverlayPosition: ({ message, sender }) => + this.updateOverlayPosition(message, sender), + updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message), + updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), + collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), + unlockCompleted: ({ message }) => this.unlockCompleted(message), + addedCipher: () => this.updateOverlayCiphers(), + addEditCipherSubmitted: () => this.updateOverlayCiphers(), + editedCipher: () => this.updateOverlayCiphers(), + deletedCipher: () => this.updateOverlayCiphers(), + }; + private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { + overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port), + closeAutofillOverlay: ({ port }) => this.closeOverlay(port), + forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), + overlayPageBlurred: () => this.checkOverlayListFocused(), + redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + }; + private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = { + checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(), + forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), + 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 domainSettingsService: DomainSettingsService, + private autofillSettingsService: AutofillSettingsServiceAbstraction, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private themeStateService: ThemeStateService, + ) {} + + /** + * 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) { + if (!this.pageDetailsForTab[tabId]) { + return; + } + + this.pageDetailsForTab[tabId].clear(); + 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(); + const env = await firstValueFrom(this.environmentService.environment$); + this.iconsServerUrl = env.getIconsUrl(); + 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() { + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + if (authStatus !== 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 = await 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 async getOverlayCipherData(): Promise { + const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); + const overlayCiphersArray = Array.from(this.overlayLoginCiphers); + const overlayCipherData: 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, showFavicons); + } + + 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, showFavicons), + login: cipher.type === CipherType.Login ? { username: 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, + }; + + const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; + if (!pageDetailsMap) { + this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]); + return; + } + + pageDetailsMap.set(sender.frameId, 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, + ) { + const pageDetails = this.pageDetailsForTab[sender.tab.id]; + if (!overlayCipherId || !pageDetails?.size) { + return; + } + + const cipher = this.overlayLoginCiphers.get(overlayCipherId); + + if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { + return; + } + const totpCode = await this.autofillService.doAutoFill({ + tab: sender.tab, + cipher: cipher, + pageDetails: Array.from(pageDetails.values()), + fillNewPassword: true, + allowTotpAutofill: true, + }); + + if (totpCode) { + this.platformUtilsService.copyToClipboard(totpCode); + } + + 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 + * @param forceCloseOverlay - Identifies whether the overlay should be force closed + */ + private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + BrowserApi.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay }); + } + + /** + * 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 + * @param sender - The sender of the port message + */ + private overlayElementClosed( + { overlayElement }: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + if (sender.tab.id !== this.focusedFieldData?.tabId) { + this.expiredPorts.forEach((port) => port.disconnect()); + this.expiredPorts = []; + return; + } + + 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 + * @param sender - The sender of the port message + */ + private updateOverlayPosition( + { overlayElement }: { overlayElement?: string }, + sender: chrome.runtime.MessageSender, + ) { + if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) { + 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. + * @param sender - The sender of the extension message + */ + private setFocusedFieldData( + { focusedFieldData }: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id }; + } + + /** + * 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(), + }); + } + + /** + * Gets the overlay's visibility setting from the settings service. + */ + private async getOverlayVisibility(): Promise { + return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); + } + + /** + * 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; + } + + /** + * 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) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.unlockVault(port); + return; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + 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: LockedVaultPendingNotificationsData = { + commandToRetry: { message: { 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?.message?.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; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + 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) { + void 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.cipherService.setAddEditCipherInfo({ + cipher: cipherView, + collectionIds: cipherView.collectionIds, + }); + + await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); + await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); + } + + /** + * Sets up the extension message listeners for the overlay. + */ + private setupExtensionMessageListeners() { + BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); + BrowserApi.addListener(chrome.runtime.onConnect, 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 null; + } + + const messageResponse = handler({ message, sender }); + if (typeof messageResponse === "undefined") { + return null; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + 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; + } + + this.storeOverlayPort(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: await firstValueFrom(this.themeStateService.selectedTheme$), + translations: this.getTranslations(), + ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null, + }); + this.updateOverlayPosition( + { + overlayElement: isOverlayListPort + ? AutofillOverlayElement.List + : AutofillOverlayElement.Button, + }, + port.sender, + ); + }; + + /** + * Stores the connected overlay port and sets up any existing ports to be disconnected. + * + * @param port - The port to store +| */ + private storeOverlayPort(port: chrome.runtime.Port) { + if (port.name === AutofillOverlayPort.List) { + this.storeExpiredOverlayPort(this.overlayListPort); + this.overlayListPort = port; + return; + } + + if (port.name === AutofillOverlayPort.Button) { + this.storeExpiredOverlayPort(this.overlayButtonPort); + this.overlayButtonPort = port; + } + } + + /** + * When registering a new connection, we want to ensure that the port is disconnected. + * This method places an existing port in the expiredPorts array to be disconnected + * at a later time. + * + * @param port - The port to store in the expiredPorts array + */ + private storeExpiredOverlayPort(port: chrome.runtime.Port | null) { + if (port) { + this.expiredPorts.push(port); + } + } + + /** + * 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 LegacyOverlayBackground; diff --git a/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts new file mode 100644 index 0000000000..ed422822b3 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts @@ -0,0 +1,41 @@ +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +import AutofillScript from "../../../models/autofill-script"; + +type AutofillExtensionMessage = { + command: string; + tab?: chrome.tabs.Tab; + sender?: string; + fillScript?: AutofillScript; + url?: string; + pageDetailsUrl?: string; + ciphers?: any; + data?: { + authStatus?: AuthenticationStatus; + isFocusingFieldElement?: boolean; + isOverlayCiphersPopulated?: boolean; + direction?: "previous" | "next"; + isOpeningFullOverlay?: boolean; + forceCloseOverlay?: boolean; + autofillOverlayVisibility?: number; + }; +}; + +type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; + +type AutofillExtensionMessageHandlers = { + [key: string]: CallableFunction; + collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void; + collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void; + fillForm: ({ message }: AutofillExtensionMessageParam) => void; + openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; + closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; + addNewVaultItemFromOverlay: () => void; + redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void; + updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; + bgUnlockPopoutOpened: () => void; + bgVaultItemRepromptPopoutOpened: () => void; + updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void; +}; + +export { AutofillExtensionMessage, AutofillExtensionMessageHandlers }; diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts new file mode 100644 index 0000000000..96d5e85ca3 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts @@ -0,0 +1,604 @@ +import { mock } from "jest-mock-extended"; + +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; + +import { RedirectFocusDirection } from "../../enums/autofill-overlay.enum"; +import AutofillPageDetails from "../../models/autofill-page-details"; +import AutofillScript from "../../models/autofill-script"; +import { + flushPromises, + mockQuerySelectorAllDefinedCall, + sendMockExtensionMessage, +} from "../../spec/testing-utils"; +import AutofillOverlayContentServiceDeprecated from "../services/autofill-overlay-content.service.deprecated"; + +import { AutofillExtensionMessage } from "./abstractions/autofill-init.deprecated"; +import AutofillInitDeprecated from "./autofill-init.deprecated"; + +describe("AutofillInit", () => { + let autofillInit: AutofillInitDeprecated; + const autofillOverlayContentService = mock(); + const originalDocumentReadyState = document.readyState; + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); + + beforeEach(() => { + chrome.runtime.connect = jest.fn().mockReturnValue({ + onDisconnect: { + addListener: jest.fn(), + }, + }); + autofillInit = new AutofillInitDeprecated(autofillOverlayContentService); + window.IntersectionObserver = jest.fn(() => mock()); + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Object.defineProperty(document, "readyState", { + value: originalDocumentReadyState, + writable: true, + }); + }); + + afterAll(() => { + mockQuerySelectorAll.mockRestore(); + }); + + describe("init", () => { + it("sets up the extension message listeners", () => { + jest.spyOn(autofillInit as any, "setupExtensionMessageListeners"); + + autofillInit.init(); + + expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled(); + }); + + it("triggers a collection of page details if the document is in a `complete` ready state", () => { + jest.useFakeTimers(); + Object.defineProperty(document, "readyState", { value: "complete", writable: true }); + + autofillInit.init(); + jest.advanceTimersByTime(250); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + { + command: "bgCollectPageDetails", + sender: "autofillInit", + }, + expect.any(Function), + ); + }); + + it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { + jest.spyOn(window, "addEventListener"); + Object.defineProperty(document, "readyState", { value: "loading", writable: true }); + + autofillInit.init(); + + expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("sets up a chrome runtime on message listener", () => { + jest.spyOn(chrome.runtime.onMessage, "addListener"); + + autofillInit["setupExtensionMessageListeners"](); + + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + }); + + describe("handleExtensionMessage", () => { + let message: AutofillExtensionMessage; + let sender: chrome.runtime.MessageSender; + const sendResponse = jest.fn(); + + beforeEach(() => { + message = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + sender = mock(); + }); + + it("returns a undefined value if a extension message handler is not found with the given message command", () => { + message.command = "unknownCommand"; + + const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + + expect(response).toBe(null); + }); + + 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 = "removeAutofillOverlay"; + message.fillScript = mock(); + + const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + expect(response2).toBe(null); + }); + + it("returns a true value and calls sendResponse if the message handler returns a response", async () => { + message.command = "collectPageDetailsImmediately"; + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + 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(), + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendMockExtensionMessage(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); + + sendMockExtensionMessage( + { command: "collectPageDetailsImmediately" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled(); + expect(sendResponse).toBeCalledWith(pageDetails); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + }); + }); + + describe("fillForm", () => { + let fillScript: AutofillScript; + beforeEach(() => { + fillScript = mock(); + 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(); + const message = { + command: "fillForm", + fillScript, + pageDetailsUrl: "https://a-different-url.com", + }; + + sendMockExtensionMessage(message); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( + fillScript, + ); + }); + + it("calls the InsertAutofillContentService to fill the form", async () => { + sendMockExtensionMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + }); + + it("removes the overlay when filling the form", async () => { + const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); + sendMockExtensionMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(blurAndRemoveOverlaySpy).toHaveBeenCalled(); + }); + + it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { + jest.useFakeTimers(); + jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") + .mockImplementation(); + + sendMockExtensionMessage({ + 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); + }); + + it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { + jest.useFakeTimers(); + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") + .mockImplementation(); + + sendMockExtensionMessage({ + 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 AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("opens the autofill overlay", () => { + sendMockExtensionMessage(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("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ + command: "closeAutofillOverlay", + data: { forceCloseOverlay: false }, + }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("removes the autofill overlay if the message flags a forced closure", () => { + sendMockExtensionMessage({ + command: "closeAutofillOverlay", + data: { forceCloseOverlay: true }, + }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).toHaveBeenCalled(); + }); + + it("ignores the message if a field is currently focused", () => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; + + sendMockExtensionMessage({ 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; + + sendMockExtensionMessage({ 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", () => { + sendMockExtensionMessage({ 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 AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("will add a new vault item", () => { + sendMockExtensionMessage({ 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 AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("redirects the overlay focus", () => { + sendMockExtensionMessage(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 AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("updates whether the overlay ciphers are populated", () => { + sendMockExtensionMessage(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 AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ 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"); + + sendMockExtensionMessage({ 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 AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ 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"); + + sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + + describe("updateAutofillOverlayVisibility", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = + AutofillOverlayVisibility.OnButtonClick; + }); + + it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { + sendMockExtensionMessage({ + command: "updateAutofillOverlayVisibility", + data: {}, + }); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + AutofillOverlayVisibility.OnButtonClick, + ); + }); + + it("updates the overlay visibility value", () => { + const message = { + command: "updateAutofillOverlayVisibility", + data: { + autofillOverlayVisibility: AutofillOverlayVisibility.Off, + }, + }; + + sendMockExtensionMessage(message); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + message.data.autofillOverlayVisibility, + ); + }); + }); + }); + }); + + describe("destroy", () => { + it("clears the timeout used to collect page details on load", () => { + jest.spyOn(window, "clearTimeout"); + + autofillInit.init(); + autofillInit.destroy(); + + expect(window.clearTimeout).toHaveBeenCalledWith( + autofillInit["collectPageDetailsOnLoadTimeout"], + ); + }); + + it("removes the extension message listeners", () => { + autofillInit.destroy(); + + expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + + it("destroys the collectAutofillContentService", () => { + jest.spyOn(autofillInit["collectAutofillContentService"], "destroy"); + + autofillInit.destroy(); + + expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts new file mode 100644 index 0000000000..3e36fa43bb --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts @@ -0,0 +1,310 @@ +import { AutofillInit } from "../../content/abstractions/autofill-init"; +import AutofillPageDetails from "../../models/autofill-page-details"; +import CollectAutofillContentService from "../../services/collect-autofill-content.service"; +import DomElementVisibilityService from "../../services/dom-element-visibility.service"; +import InsertAutofillContentService from "../../services/insert-autofill-content.service"; +import { sendExtensionMessage } from "../../utils"; +import { LegacyAutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; + +import { + AutofillExtensionMessage, + AutofillExtensionMessageHandlers, +} from "./abstractions/autofill-init.deprecated"; + +class LegacyAutofillInit implements AutofillInit { + private readonly autofillOverlayContentService: LegacyAutofillOverlayContentService | undefined; + private readonly domElementVisibilityService: DomElementVisibilityService; + private readonly collectAutofillContentService: CollectAutofillContentService; + private readonly insertAutofillContentService: InsertAutofillContentService; + private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined; + private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = { + collectPageDetails: ({ message }) => this.collectPageDetails(message), + collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), + fillForm: ({ message }) => this.fillForm(message), + openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message), + closeAutofillOverlay: ({ message }) => this.removeAutofillOverlay(message), + addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(), + redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message), + updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), + bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), + bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), + updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message), + }; + + /** + * AutofillInit constructor. Initializes the DomElementVisibilityService, + * CollectAutofillContentService and InsertAutofillContentService classes. + * + * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. + */ + constructor(autofillOverlayContentService?: LegacyAutofillOverlayContentService) { + this.autofillOverlayContentService = autofillOverlayContentService; + this.domElementVisibilityService = new DomElementVisibilityService(); + this.collectAutofillContentService = new CollectAutofillContentService( + this.domElementVisibilityService, + this.autofillOverlayContentService, + ); + this.insertAutofillContentService = new InsertAutofillContentService( + this.domElementVisibilityService, + this.collectAutofillContentService, + ); + } + + /** + * Initializes the autofill content script, setting up + * the extension message listeners. This method should + * be called once when the content script is loaded. + */ + init() { + this.setupExtensionMessageListeners(); + this.autofillOverlayContentService?.init(); + this.collectPageDetailsOnLoad(); + } + + /** + * Triggers a collection of the page details from the + * background script, ensuring that autofill is ready + * to act on the page. + */ + private collectPageDetailsOnLoad() { + const sendCollectDetailsMessage = () => { + this.clearCollectPageDetailsOnLoadTimeout(); + this.collectPageDetailsOnLoadTimeout = setTimeout( + () => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), + 250, + ); + }; + + if (globalThis.document.readyState === "complete") { + sendCollectDetailsMessage(); + } + + globalThis.addEventListener("load", sendCollectDetailsMessage); + } + + /** + * Collects the page details and sends them to the + * extension background script. If the `sendDetailsInResponse` + * parameter is set to true, the page details will be + * returned to facilitate sending the details in the + * response to the extension message. + * + * @param message - The extension message. + * @param sendDetailsInResponse - Determines whether to send the details in the response. + */ + private async collectPageDetails( + message: AutofillExtensionMessage, + sendDetailsInResponse = false, + ): Promise { + const pageDetails: AutofillPageDetails = + await this.collectAutofillContentService.getPageDetails(); + if (sendDetailsInResponse) { + return pageDetails; + } + + void chrome.runtime.sendMessage({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + } + + /** + * Fills the form with the given fill script. + * + * @param {AutofillExtensionMessage} message + */ + private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) { + if ((document.defaultView || window).location.href !== pageDetailsUrl) { + return; + } + + this.blurAndRemoveOverlay(); + this.updateOverlayIsCurrentlyFilling(true); + await this.insertAutofillContentService.fillForm(fillScript); + + if (!this.autofillOverlayContentService) { + return; + } + + setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250); + } + + /** + * 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(message?: AutofillExtensionMessage) { + if (message?.data?.forceCloseOverlay) { + this.autofillOverlayContentService?.removeAutofillOverlay(); + return; + } + + 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, + ); + } + + /** + * Updates the autofill overlay visibility. + * + * @param data - Contains the autoFillOverlayVisibility value + */ + private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) { + return; + } + + this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility; + } + + /** + * Clears the send collect details message timeout. + */ + private clearCollectPageDetailsOnLoadTimeout() { + if (this.collectPageDetailsOnLoadTimeout) { + clearTimeout(this.collectPageDetailsOnLoadTimeout); + } + } + + /** + * 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 message - The extension message. + * @param sender - The message sender. + * @param sendResponse - The send response callback. + */ + private handleExtensionMessage = ( + message: AutofillExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void, + ): boolean => { + const command: string = message.command; + const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; + if (!handler) { + return null; + } + + const messageResponse = handler({ message, sender }); + if (typeof messageResponse === "undefined") { + return null; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve(messageResponse).then((response) => sendResponse(response)); + return true; + }; + + /** + * Handles destroying the autofill init content script. Removes all + * listeners, timeouts, and object instances to prevent memory leaks. + */ + destroy() { + this.clearCollectPageDetailsOnLoadTimeout(); + chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); + this.collectAutofillContentService.destroy(); + this.autofillOverlayContentService?.destroy(); + } +} + +export default LegacyAutofillInit; diff --git a/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts b/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts new file mode 100644 index 0000000000..66d672172a --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts @@ -0,0 +1,14 @@ +import { setupAutofillInitDisconnectAction } from "../../utils"; +import LegacyAutofillOverlayContentService from "../services/autofill-overlay-content.service.deprecated"; + +import LegacyAutofillInit from "./autofill-init.deprecated"; + +(function (windowContext) { + if (!windowContext.bitwardenAutofillInit) { + const autofillOverlayContentService = new LegacyAutofillOverlayContentService(); + windowContext.bitwardenAutofillInit = new LegacyAutofillInit(autofillOverlayContentService); + setupAutofillInitDisconnectAction(windowContext); + + windowContext.bitwardenAutofillInit.init(); + } +})(window); diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts similarity index 100% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts similarity index 100% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts similarity index 96% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts index b656f238dc..83578b1304 100644 --- a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts +++ b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts @@ -1,6 +1,6 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { OverlayCipherData } from "../../background/abstractions/overlay.background"; +import { OverlayCipherData } from "../../background/abstractions/overlay.background.deprecated"; type OverlayListMessage = { command: string }; diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts similarity index 89% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts index eb3c2fa4a7..368ae4e730 100644 --- a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts +++ b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts @@ -1,5 +1,5 @@ -import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button"; -import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list"; +import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button.deprecated"; +import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list.deprecated"; type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers; diff --git a/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap b/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap similarity index 95% rename from apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap rename to apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap index cb8e4a541b..132bd96889 100644 --- a/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap +++ b/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap @@ -15,7 +15,7 @@ exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's att `; + }); + + it("returns null if the sub frame URL cannot be parsed correctly", async () => { + delete globalThis.location; + globalThis.location = { href: "invalid-base" } as Location; + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(null); + }); + + it("calculates the sub frame's offsets if a single frame with the referenced url exists", async () => { + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith({ + frameId: undefined, + left: 2, + top: 2, + url: iframeSource, + }); + }); + + it("returns null if a matching iframe is not found", async () => { + document.body.innerHTML = ""; + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(null); + }); + + it("returns null if two or more iframes are found with the same src", async () => { + document.body.innerHTML = ` + + + `; + + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(null); + }); + }); + + describe("getSubFrameOffsetsFromWindowMessage", () => { + it("sends a message to the parent to calculate the sub frame positioning", () => { + jest.spyOn(globalThis.parent, "postMessage").mockImplementation(); + const subFrameId = 10; + + sendMockExtensionMessage({ + command: "getSubFrameOffsetsFromWindowMessage", + subFrameId, + }); + + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "calculateSubFramePositioning", + subFrameData: { + url: window.location.href, + frameId: subFrameId, + left: 0, + top: 0, + parentFrameIds: [0], + subFrameDepth: 0, + }, + }, + "*", + ); + }); + + describe("calculateSubFramePositioning", () => { + beforeEach(() => { + autofillOverlayContentService.init(); + jest.spyOn(globalThis.parent, "postMessage"); + document.body.innerHTML = ``; + }); + + it("destroys the inline menu listeners on the origin frame if the depth exceeds the threshold", async () => { + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + subFrameDepth: MAX_SUB_FRAME_DEPTH, + }; + sendExtensionMessageSpy.mockResolvedValue(4); + + postWindowMessage( + { command: "calculateSubFramePositioning", subFrameData }, + "*", + iframe.contentWindow as any, + ); + await flushPromises(); + + expect(globalThis.parent.postMessage).not.toHaveBeenCalled(); + }); + + it("calculates the sub frame offset for the current frame and sends those values to the parent if not in the top frame", async () => { + Object.defineProperty(window, "top", { + value: null, + writable: true, + }); + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + subFrameDepth: 0, + }; + + postWindowMessage( + { command: "calculateSubFramePositioning", subFrameData }, + "*", + iframe.contentWindow as any, + ); + await flushPromises(); + + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "calculateSubFramePositioning", + subFrameData: { + frameId: 10, + left: expect.any(Number), + parentFrameIds: [1, 2, 3], + top: expect.any(Number), + url: "https://example.com/", + subFrameDepth: expect.any(Number), + }, + }, + "*", + ); + }); + + it("posts the calculated sub frame data to the background", async () => { + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + subFrameDepth: expect.any(Number), + }; + + postWindowMessage( + { command: "calculateSubFramePositioning", subFrameData }, + "*", + iframe.contentWindow as any, + ); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateSubFrameData", { + subFrameData: { + frameId: 10, + left: expect.any(Number), + top: expect.any(Number), + url: "https://example.com/", + parentFrameIds: [1, 2, 3, 4], + subFrameDepth: expect.any(Number), + }, + }); + }); + }); + }); + + describe("checkMostRecentlyFocusedFieldHasValue message handler", () => { + it("returns true if the most recently focused field has a truthy value", async () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = mock< + ElementWithOpId + >({ value: "test" }); + + sendMockExtensionMessage( + { + command: "checkMostRecentlyFocusedFieldHasValue", + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(true); + }); + }); + + describe("setupRebuildSubFrameOffsetsListeners message handler", () => { + let autofillFieldElement: ElementWithOpId; + + beforeEach(() => { + Object.defineProperty(window, "top", { + value: null, + writable: true, + }); + jest.spyOn(globalThis, "addEventListener"); + jest.spyOn(globalThis.document.body, "addEventListener"); + document.body.innerHTML = ` +
+ + +
+ `; + autofillFieldElement = document.getElementById( + "username-field", + ) as ElementWithOpId; + }); + + describe("skipping the setup of the sub frame listeners", () => { + it('skips setup when the window is the "top" frame', async () => { + Object.defineProperty(window, "top", { + value: window, + writable: true, + }); + + sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + await flushPromises(); + + expect(globalThis.addEventListener).not.toHaveBeenCalledWith( + EVENTS.FOCUS, + expect.any(Function), + ); + expect(globalThis.document.body.addEventListener).not.toHaveBeenCalledWith( + EVENTS.MOUSEENTER, + expect.any(Function), + ); + }); + + it("skips setup when no form fields exist on the current frame", async () => { + autofillOverlayContentService["formFieldElements"] = new Set(); + + sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + await flushPromises(); + + expect(globalThis.addEventListener).not.toHaveBeenCalledWith( + EVENTS.FOCUS, + expect.any(Function), + ); + expect(globalThis.document.body.addEventListener).not.toHaveBeenCalledWith( + EVENTS.MOUSEENTER, + expect.any(Function), + ); + }); + }); + + it("sets up the sub frame rebuild listeners when the sub frame contains fields", async () => { + autofillOverlayContentService["formFieldElements"].add(autofillFieldElement); + + sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + await flushPromises(); + + expect(globalThis.addEventListener).toHaveBeenCalledWith( + EVENTS.FOCUS, + expect.any(Function), + ); + expect(globalThis.document.body.addEventListener).toHaveBeenCalledWith( + EVENTS.MOUSEENTER, + expect.any(Function), + ); + }); + + describe("triggering the sub frame listener", () => { + beforeEach(async () => { + autofillOverlayContentService["formFieldElements"].add(autofillFieldElement); + await sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + }); + + it("triggers a rebuild of the sub frame listener when a focus event occurs", async () => { + globalThis.dispatchEvent(new Event(EVENTS.FOCUS)); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("triggerSubFrameFocusInRebuild"); + }); + }); + }); + + describe("destroyAutofillInlineMenuListeners message handler", () => { + it("destroys the inline menu listeners", () => { + jest.spyOn(autofillOverlayContentService, "destroy"); + + sendMockExtensionMessage({ command: "destroyAutofillInlineMenuListeners" }); + + expect(autofillOverlayContentService.destroy).toHaveBeenCalled(); + }); }); }); @@ -1670,36 +1679,18 @@ describe("AutofillOverlayContentService", () => { forms: { validFormId: mock() }, fields: [autofillFieldData, passwordFieldData], }); - void autofillOverlayContentService.setupAutofillOverlayListenerOnField( + void autofillOverlayContentService.setupInlineMenu( autofillFieldElement, autofillFieldData, pageDetailsMock, ); autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; - }); - - it("disconnects all mutation observers", () => { - autofillOverlayContentService["setupMutationObserver"](); - jest.spyOn(autofillOverlayContentService["bodyElementMutationObserver"], "disconnect"); - - autofillOverlayContentService.destroy(); - - expect( - autofillOverlayContentService["bodyElementMutationObserver"].disconnect, - ).toHaveBeenCalled(); - }); - - it("clears the user interaction event timeout", () => { - jest.spyOn(autofillOverlayContentService as any, "clearUserInteractionEventTimeout"); - - autofillOverlayContentService.destroy(); - - expect(autofillOverlayContentService["clearUserInteractionEventTimeout"]).toHaveBeenCalled(); + jest.spyOn(globalThis, "clearTimeout"); + jest.spyOn(globalThis.document, "removeEventListener"); + jest.spyOn(globalThis, "removeEventListener"); }); it("de-registers all global event listeners", () => { - jest.spyOn(globalThis.document, "removeEventListener"); - jest.spyOn(globalThis, "removeEventListener"); jest.spyOn(autofillOverlayContentService as any, "removeOverlayRepositionEventListeners"); autofillOverlayContentService.destroy(); @@ -1739,5 +1730,22 @@ describe("AutofillOverlayContentService", () => { autofillFieldElement, ); }); + + it("clears all existing timeouts", () => { + autofillOverlayContentService["focusInlineMenuListTimeout"] = setTimeout(jest.fn(), 100); + autofillOverlayContentService["closeInlineMenuOnRedirectTimeout"] = setTimeout( + jest.fn(), + 100, + ); + + autofillOverlayContentService.destroy(); + + expect(clearTimeout).toHaveBeenCalledWith( + autofillOverlayContentService["focusInlineMenuListTimeout"], + ); + expect(clearTimeout).toHaveBeenCalledWith( + autofillOverlayContentService["closeInlineMenuOnRedirectTimeout"], + ); + }); }); }); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index d56a8a80cc..8148ab98d8 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -3,71 +3,79 @@ import "lit/polyfill-support.js"; import { FocusableElement, tabbable } from "tabbable"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { + EVENTS, + AutofillOverlayVisibility, + AUTOFILL_OVERLAY_HANDLE_REPOSITION, +} from "@bitwarden/common/autofill/constants"; -import { FocusedFieldData } from "../background/abstractions/overlay.background"; +import { + FocusedFieldData, + SubFrameOffsetData, +} from "../background/abstractions/overlay.background"; +import { AutofillExtensionMessage } from "../content/abstractions/autofill-init"; +import { + AutofillOverlayElement, + MAX_SUB_FRAME_DEPTH, + RedirectFocusDirection, +} from "../enums/autofill-overlay.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; -import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe"; -import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { elementIsFillableFormField, - generateRandomCustomElementName, + getAttributeBoolean, sendExtensionMessage, - setElementStyles, + throttle, } from "../utils"; -import { AutofillOverlayElement, RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { + AutofillOverlayContentExtensionMessageHandlers, AutofillOverlayContentService as AutofillOverlayContentServiceInterface, - OpenAutofillOverlayOptions, + OpenAutofillInlineMenuOptions, + SubFrameDataFromWindowMessage, } from "./abstractions/autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; import { AutoFillConstants } from "./autofill-constants"; -import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service"; -class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface { - private readonly inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; - isFieldCurrentlyFocused = false; - isCurrentlyFilling = false; - isOverlayCiphersPopulated = false; +export class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface { pageDetailsUpdateRequired = false; - autofillOverlayVisibility: number; - private isFirefoxBrowser = - globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 || - globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1; - private readonly generateRandomCustomElementName = generateRandomCustomElementName; + inlineMenuVisibility: number; private readonly findTabs = tabbable; private readonly sendExtensionMessage = sendExtensionMessage; private formFieldElements: Set> = new Set([]); - private ignoredFieldTypes: Set = new Set(AutoFillConstants.ExcludedOverlayTypes); + private hiddenFormFieldElements: WeakMap, AutofillField> = + new WeakMap(); + private ignoredFieldTypes: Set = new Set(AutoFillConstants.ExcludedInlineMenuTypes); private userFilledFields: Record = {}; private authStatus: AuthenticationStatus; private focusableElements: FocusableElement[] = []; - private isOverlayButtonVisible = false; - private isOverlayListVisible = false; - private overlayButtonElement: HTMLElement; - private overlayListElement: HTMLElement; private mostRecentlyFocusedField: ElementWithOpId; private focusedFieldData: FocusedFieldData; - private userInteractionEventTimeout: number | NodeJS.Timeout; - private overlayElementsMutationObserver: MutationObserver; - private bodyElementMutationObserver: MutationObserver; - private documentElementMutationObserver: MutationObserver; - private mutationObserverIterations = 0; - private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; - private autofillFieldKeywordsMap: WeakMap = new WeakMap(); + private closeInlineMenuOnRedirectTimeout: number | NodeJS.Timeout; + private focusInlineMenuListTimeout: number | NodeJS.Timeout; private eventHandlersMemo: { [key: string]: EventListener } = {}; - private readonly customElementDefaultStyles: Partial = { - all: "initial", - position: "fixed", - display: "block", - zIndex: "2147483647", + private readonly extensionMessageHandlers: AutofillOverlayContentExtensionMessageHandlers = { + openAutofillInlineMenu: ({ message }) => this.openInlineMenu(message), + addNewVaultItemFromOverlay: () => this.addNewVaultItem(), + blurMostRecentlyFocusedField: () => this.blurMostRecentlyFocusedField(), + unsetMostRecentlyFocusedField: () => this.unsetMostRecentlyFocusedField(), + checkIsMostRecentlyFocusedFieldWithinViewport: () => + this.checkIsMostRecentlyFocusedFieldWithinViewport(), + bgUnlockPopoutOpened: () => this.blurMostRecentlyFocusedField(true), + bgVaultItemRepromptPopoutOpened: () => this.blurMostRecentlyFocusedField(true), + redirectAutofillInlineMenuFocusOut: ({ message }) => + this.redirectInlineMenuFocusOut(message?.data?.direction), + updateAutofillInlineMenuVisibility: ({ message }) => this.updateInlineMenuVisibility(message), + getSubFrameOffsets: ({ message }) => this.getSubFrameOffsets(message), + getSubFrameOffsetsFromWindowMessage: ({ message }) => + this.getSubFrameOffsetsFromWindowMessage(message), + checkMostRecentlyFocusedFieldHasValue: () => this.mostRecentlyFocusedFieldHasValue(), + setupRebuildSubFrameOffsetsListeners: () => this.setupRebuildSubFrameOffsetsListeners(), + destroyAutofillInlineMenuListeners: () => this.destroy(), }; - constructor() { - this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); - } + constructor(private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService) {} /** * Initializes the autofill overlay content service by setting up the mutation observers. @@ -83,14 +91,22 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Sets up the autofill overlay listener on the form field element. This method is called + * Getter used to access the extension message handlers associated + * with the autofill overlay content service. + */ + get messageHandlers(): AutofillOverlayContentExtensionMessageHandlers { + return this.extensionMessageHandlers; + } + + /** + * Sets up the autofill inline menu listener on the form field element. This method is called * during the page details collection process. * * @param formFieldElement - Form field elements identified during the page details collection process. * @param autofillFieldData - Autofill field data captured from the form field element. * @param pageDetails - The collected page details from the tab. */ - async setupAutofillOverlayListenerOnField( + async setupInlineMenu( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, @@ -102,49 +118,36 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte return; } - this.formFieldElements.add(formFieldElement); - - if (!this.autofillOverlayVisibility) { - await this.getAutofillOverlayVisibility(); - } - - this.setupFormFieldElementEventListeners(formFieldElement); - - if (this.getRootNodeActiveElement(formFieldElement) === formFieldElement) { - await this.triggerFormFieldFocusedAction(formFieldElement); + if (this.isHiddenField(formFieldElement, autofillFieldData)) { return; } - if (!this.mostRecentlyFocusedField) { - await this.updateMostRecentlyFocusedField(formFieldElement); - } + await this.setupInlineMenuOnQualifiedField(formFieldElement); } /** - * Handles opening the autofill overlay. Will conditionally open - * the overlay based on the current autofill overlay visibility setting. - * Allows you to optionally focus the field element when opening the overlay. - * Will also optionally ignore the overlay visibility setting and open the + * Handles opening the autofill inline menu. Will conditionally open + * the inline menu based on the current inline menu visibility setting. + * Allows you to optionally focus the field element when opening the inline menu. + * Will also optionally ignore the inline menu visibility setting and open the * - * @param options - Options for opening the autofill overlay. + * @param options - Options for opening the autofill inline menu. */ - openAutofillOverlay(options: OpenAutofillOverlayOptions = {}) { - const { isFocusingFieldElement, isOpeningFullOverlay, authStatus } = options; + openInlineMenu(options: OpenAutofillInlineMenuOptions = {}) { + const { isFocusingFieldElement, isOpeningFullInlineMenu, authStatus } = options; if (!this.mostRecentlyFocusedField) { return; } if (this.pageDetailsUpdateRequired) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("bgCollectPageDetails", { + void this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillOverlayContentService", }); this.pageDetailsUpdateRequired = false; } if (isFocusingFieldElement && !this.recentlyFocusedFieldIsCurrentlyFocused()) { - this.focusMostRecentOverlayField(); + this.focusMostRecentlyFocusedField(); } if (typeof authStatus !== "undefined") { @@ -152,79 +155,47 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } if ( - this.autofillOverlayVisibility === AutofillOverlayVisibility.OnButtonClick && - !isOpeningFullOverlay + this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick && + !isOpeningFullInlineMenu ) { - this.updateOverlayButtonPosition(); + this.updateInlineMenuButtonPosition(); return; } - this.updateOverlayElementsPosition(); + this.updateInlineMenuElementsPosition(); } /** * Focuses the most recently focused field element. */ - focusMostRecentOverlayField() { + focusMostRecentlyFocusedField() { this.mostRecentlyFocusedField?.focus(); } /** * Removes focus from the most recently focused field element. */ - blurMostRecentOverlayField() { + blurMostRecentlyFocusedField(isClosingInlineMenu: boolean = false) { this.mostRecentlyFocusedField?.blur(); + + if (isClosingInlineMenu) { + void this.sendExtensionMessage("closeAutofillInlineMenu"); + } } /** - * Removes the autofill overlay from the page. This will initially - * unobserve the body element to ensure the mutation observer no - * longer triggers. + * Sets the most recently focused field within the current frame to a `null` value. */ - removeAutofillOverlay = () => { - this.removeBodyElementObserver(); - this.removeAutofillOverlayButton(); - this.removeAutofillOverlayList(); - }; - - /** - * Removes the overlay button from the DOM if it is currently present. Will - * also remove the overlay reposition event listeners. - */ - removeAutofillOverlayButton() { - if (!this.overlayButtonElement) { - return; - } - - this.overlayButtonElement.remove(); - this.isOverlayButtonVisible = false; - void this.sendExtensionMessage("autofillOverlayElementClosed", { - overlayElement: AutofillOverlayElement.Button, - }); - this.removeOverlayRepositionEventListeners(); - } - - /** - * Removes the overlay list from the DOM if it is currently present. - */ - removeAutofillOverlayList() { - if (!this.overlayListElement) { - return; - } - - this.overlayListElement.remove(); - this.isOverlayListVisible = false; - void this.sendExtensionMessage("autofillOverlayElementClosed", { - overlayElement: AutofillOverlayElement.List, - }); + unsetMostRecentlyFocusedField() { + this.mostRecentlyFocusedField = null; } /** * Formats any found user filled fields for a login cipher and sends a message * to the background script to add a new cipher. */ - addNewVaultItem() { - if (!this.isOverlayListVisible) { + async addNewVaultItem() { + if (!(await this.isInlineMenuListVisible())) { return; } @@ -235,26 +206,27 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte hostname: globalThis.document.location.hostname, }; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("autofillOverlayAddNewVaultItem", { login }); + void this.sendExtensionMessage("autofillOverlayAddNewVaultItem", { login }); } /** - * Redirects the keyboard focus out of the overlay, selecting the element that is + * Redirects the keyboard focus out of the inline menu, selecting the element that is * either previous or next in the tab order. If the direction is current, the most * recently focused field will be focused. * - * @param direction - The direction to redirect the focus. + * @param direction - The direction to redirect the focus out. */ - redirectOverlayFocusOut(direction: string) { - if (!this.isOverlayListVisible || !this.mostRecentlyFocusedField) { + private async redirectInlineMenuFocusOut(direction?: string) { + if (!direction || !this.mostRecentlyFocusedField || !(await this.isInlineMenuListVisible())) { return; } if (direction === RedirectFocusDirection.Current) { - this.focusMostRecentOverlayField(); - setTimeout(this.removeAutofillOverlay, 100); + this.focusMostRecentlyFocusedField(); + this.closeInlineMenuOnRedirectTimeout = globalThis.setTimeout( + () => void this.sendExtensionMessage("closeAutofillInlineMenu"), + 100, + ); return; } @@ -274,7 +246,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Sets up the event listeners that facilitate interaction with the form field elements. * Will clear any cached form field element handlers that are encountered when setting - * up a form field element to the overlay. + * up a form field element. * * @param formFieldElement - The form field element to set up the event listeners for. */ @@ -299,7 +271,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Removes any cached form field element handlers that are encountered - * when setting up a form field element to present the overlay. + * when setting up a form field element to present the inline menu. * * @param formFieldElement - The form field element to remove the cached handlers for. */ @@ -343,33 +315,35 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Form Field blur event handler. Updates the value identifying whether - * the field is focused and sends a message to check if the overlay itself + * the field is focused and sends a message to check if the inline menu itself * is currently focused. */ private handleFormFieldBlurEvent = () => { - this.isFieldCurrentlyFocused = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("checkAutofillOverlayFocused"); + void this.sendExtensionMessage("updateIsFieldCurrentlyFocused", { + isFieldCurrentlyFocused: false, + }); + void this.sendExtensionMessage("checkAutofillInlineMenuFocused"); }; /** * Form field keyup event handler. Facilitates the ability to remove the - * autofill overlay using the escape key, focusing the overlay list using - * the ArrowDown key, and ensuring that the overlay is repositioned when + * autofill inline menu using the escape key, focusing the inline menu list using + * the ArrowDown key, and ensuring that the inline menu is repositioned when * the form is submitted using the Enter key. * * @param event - The keyup event. */ - private handleFormFieldKeyupEvent = (event: KeyboardEvent) => { + private handleFormFieldKeyupEvent = async (event: KeyboardEvent) => { const eventCode = event.code; if (eventCode === "Escape") { - this.removeAutofillOverlay(); + void this.sendExtensionMessage("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); return; } - if (eventCode === "Enter" && !this.isCurrentlyFilling) { - this.handleOverlayRepositionEvent(); + if (eventCode === "Enter" && !(await this.isFieldCurrentlyFilling())) { + void this.handleOverlayRepositionEvent(); return; } @@ -377,28 +351,28 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte event.preventDefault(); event.stopPropagation(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.focusOverlayList(); + void this.focusInlineMenuList(); } }; /** - * Triggers a focus of the overlay list, if it is visible. If the list is not visible, - * the overlay will be opened and the list will be focused after a short delay. Ensures - * that the overlay list is focused when the user presses the down arrow key. + * Triggers a focus of the inline menu list, if it is visible. If the list is not visible, + * the inline menu will be opened and the list will be focused after a short delay. Ensures + * that the inline menu list is focused when the user presses the down arrow key. */ - private async focusOverlayList() { - if (!this.isOverlayListVisible && this.mostRecentlyFocusedField) { + private async focusInlineMenuList() { + if (this.mostRecentlyFocusedField && !(await this.isInlineMenuListVisible())) { + this.clearFocusInlineMenuListTimeout(); await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); - this.openAutofillOverlay({ isOpeningFullOverlay: true }); - setTimeout(() => this.sendExtensionMessage("focusAutofillOverlayList"), 125); + this.openInlineMenu({ isOpeningFullInlineMenu: true }); + this.focusInlineMenuListTimeout = globalThis.setTimeout( + () => this.sendExtensionMessage("focusAutofillInlineMenuList"), + 125, + ); return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("focusAutofillOverlayList"); + void this.sendExtensionMessage("focusAutofillInlineMenuList"); } /** @@ -416,23 +390,26 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Triggers when the form field element receives an input event. This method will * store the modified form element data for use when the user attempts to add a new - * vault item. It also acts to remove the overlay list while the user is typing. + * vault item. It also acts to remove the inline menu list while the user is typing. * * @param formFieldElement - The form field element that triggered the input event. */ - private triggerFormFieldInput(formFieldElement: ElementWithOpId) { + private async triggerFormFieldInput(formFieldElement: ElementWithOpId) { if (!elementIsFillableFormField(formFieldElement)) { return; } this.storeModifiedFormElement(formFieldElement); - if (formFieldElement.value && (this.isOverlayCiphersPopulated || !this.isUserAuthed())) { - this.removeAutofillOverlayList(); + if (await this.hideInlineMenuListOnFilledField(formFieldElement)) { + void this.sendExtensionMessage("closeAutofillInlineMenu", { + overlayElement: AutofillOverlayElement.List, + forceCloseInlineMenu: true, + }); return; } - this.openAutofillOverlay(); + this.openInlineMenu(); } /** @@ -444,8 +421,8 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte * @private */ private storeModifiedFormElement(formFieldElement: ElementWithOpId) { - if (formFieldElement === this.mostRecentlyFocusedField) { - this.mostRecentlyFocusedField = formFieldElement; + if (formFieldElement !== this.mostRecentlyFocusedField) { + void this.updateMostRecentlyFocusedField(formFieldElement); } if (formFieldElement.type === "password") { @@ -470,12 +447,12 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Triggers when the form field element receives a click event. This method will - * trigger the focused action for the form field element if the overlay is not visible. + * trigger the focused action for the form field element if the inline menu is not visible. * * @param formFieldElement - The form field element that triggered the click event. */ private async triggerFormFieldClickedAction(formFieldElement: ElementWithOpId) { - if (this.isOverlayButtonVisible || this.isOverlayListVisible) { + if ((await this.isInlineMenuButtonVisible()) || (await this.isInlineMenuListVisible())) { return; } @@ -496,37 +473,39 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Triggers when the form field element receives a focus event. This method will - * update the most recently focused field and open the autofill overlay if the + * update the most recently focused field and open the autofill inline menu if the * autofill process is not currently active. * * @param formFieldElement - The form field element that triggered the focus event. */ private async triggerFormFieldFocusedAction(formFieldElement: ElementWithOpId) { - if (this.isCurrentlyFilling) { + if (await this.isFieldCurrentlyFilling()) { return; } - this.isFieldCurrentlyFocused = true; - this.clearUserInteractionEventTimeout(); + await this.sendExtensionMessage("updateIsFieldCurrentlyFocused", { + isFieldCurrentlyFocused: true, + }); const initiallyFocusedField = this.mostRecentlyFocusedField; await this.updateMostRecentlyFocusedField(formFieldElement); - const formElementHasValue = Boolean((formFieldElement as HTMLInputElement).value); if ( - this.autofillOverlayVisibility === AutofillOverlayVisibility.OnButtonClick || - (formElementHasValue && initiallyFocusedField !== this.mostRecentlyFocusedField) + this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick || + (initiallyFocusedField !== this.mostRecentlyFocusedField && + (await this.hideInlineMenuListOnFilledField(formFieldElement as FillableFormFieldElement))) ) { - this.removeAutofillOverlayList(); + await this.sendExtensionMessage("closeAutofillInlineMenu", { + overlayElement: AutofillOverlayElement.List, + forceCloseInlineMenu: true, + }); } - if (!formElementHasValue || (!this.isOverlayCiphersPopulated && this.isUserAuthed())) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("openAutofillOverlay"); + if (await this.hideInlineMenuListOnFilledField(formFieldElement as FillableFormFieldElement)) { + this.updateInlineMenuButtonPosition(); return; } - this.updateOverlayButtonPosition(); + void this.sendExtensionMessage("openAutofillInlineMenu"); } /** @@ -547,82 +526,33 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Updates the position of both the overlay button and overlay list. + * Updates the position of both the inline menu button and list. */ - private updateOverlayElementsPosition() { - this.updateOverlayButtonPosition(); - this.updateOverlayListPosition(); + private updateInlineMenuElementsPosition() { + this.updateInlineMenuButtonPosition(); + this.updateInlineMenuListPosition(); } /** - * Updates the position of the overlay button. + * Updates the position of the inline menu button. */ - private updateOverlayButtonPosition() { - if (!this.overlayButtonElement) { - this.createAutofillOverlayButton(); - this.updateCustomElementDefaultStyles(this.overlayButtonElement); - } - - if (!this.isOverlayButtonVisible) { - this.appendOverlayElementToBody(this.overlayButtonElement); - this.isOverlayButtonVisible = true; - this.setOverlayRepositionEventListeners(); - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("updateAutofillOverlayPosition", { + private updateInlineMenuButtonPosition() { + void this.sendExtensionMessage("updateAutofillInlineMenuPosition", { overlayElement: AutofillOverlayElement.Button, }); } /** - * Updates the position of the overlay list. + * Updates the position of the inline menu list. */ - private updateOverlayListPosition() { - if (!this.overlayListElement) { - this.createAutofillOverlayList(); - this.updateCustomElementDefaultStyles(this.overlayListElement); - } - - if (!this.isOverlayListVisible) { - this.appendOverlayElementToBody(this.overlayListElement); - this.isOverlayListVisible = true; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("updateAutofillOverlayPosition", { + private updateInlineMenuListPosition() { + void this.sendExtensionMessage("updateAutofillInlineMenuPosition", { overlayElement: AutofillOverlayElement.List, }); } /** - * Appends the overlay element to the body element. This method will also - * observe the body element to ensure that the overlay element is not - * interfered with by any DOM changes. - * - * @param element - The overlay element to append to the body element. - */ - private appendOverlayElementToBody(element: HTMLElement) { - this.observeBodyElement(); - globalThis.document.body.appendChild(element); - } - - /** - * Sends a message that facilitates hiding the overlay elements. - * - * @param isHidden - Indicates if the overlay elements should be hidden. - */ - private toggleOverlayHidden(isHidden: boolean) { - const displayValue = isHidden ? "none" : "block"; - void this.sendExtensionMessage("updateAutofillOverlayHidden", { display: displayValue }); - - this.isOverlayButtonVisible = !!this.overlayButtonElement && !isHidden; - this.isOverlayListVisible = !!this.overlayListElement && !isHidden; - } - - /** - * Updates the data used to position the overlay elements in relation + * Updates the data used to position the inline menu elements in relation * to the most recently focused form field. * * @param formFieldElement - The form field element that triggered the focus event. @@ -630,6 +560,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte private async updateMostRecentlyFocusedField( formFieldElement: ElementWithOpId, ) { + if (!formFieldElement || !elementIsFillableFormField(formFieldElement)) { + return; + } + this.mostRecentlyFocusedField = formFieldElement; const { paddingRight, paddingLeft } = globalThis.getComputedStyle(formFieldElement); const { width, height, top, left } = @@ -639,9 +573,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte focusedFieldRects: { width, height, top, left }, }; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("updateFocusedFieldData", { + await this.sendExtensionMessage("updateFocusedFieldData", { focusedFieldData: this.focusedFieldData, }); } @@ -701,7 +633,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Identifies if the field should have the autofill overlay setup on it. Currently, this is mainly + * Identifies if the field should have the autofill inline menu setup on it. Currently, this is mainly * determined by whether the field correlates with a login cipher. This method will need to be * updated in the future to support other types of forms. * @@ -712,12 +644,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, ): boolean { - if ( - autofillFieldData.readonly || - autofillFieldData.disabled || - !autofillFieldData.viewable || - this.ignoredFieldTypes.has(autofillFieldData.type) - ) { + if (this.ignoredFieldTypes.has(autofillFieldData.type)) { return true; } @@ -728,354 +655,167 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Creates the autofill overlay button element. Will not attempt - * to create the element if it already exists in the DOM. - */ - private createAutofillOverlayButton() { - if (this.overlayButtonElement) { - return; - } - - if (this.isFirefoxBrowser) { - this.overlayButtonElement = globalThis.document.createElement("div"); - new AutofillOverlayButtonIframe(this.overlayButtonElement); - - return; - } - - const customElementName = this.generateRandomCustomElementName(); - globalThis.customElements?.define( - customElementName, - class extends HTMLElement { - constructor() { - super(); - new AutofillOverlayButtonIframe(this); - } - }, - ); - this.overlayButtonElement = globalThis.document.createElement(customElementName); - } - - /** - * Creates the autofill overlay list element. Will not attempt - * to create the element if it already exists in the DOM. - */ - private createAutofillOverlayList() { - if (this.overlayListElement) { - return; - } - - if (this.isFirefoxBrowser) { - this.overlayListElement = globalThis.document.createElement("div"); - new AutofillOverlayListIframe(this.overlayListElement); - - return; - } - - const customElementName = this.generateRandomCustomElementName(); - globalThis.customElements?.define( - customElementName, - class extends HTMLElement { - constructor() { - super(); - new AutofillOverlayListIframe(this); - } - }, - ); - this.overlayListElement = globalThis.document.createElement(customElementName); - } - - /** - * Updates the default styles for the custom element. This method will - * remove any styles that are added to the custom element by other methods. + * Validates whether a field is considered to be "hidden" based on the field's attributes. + * If the field is hidden, a fallback listener will be set up to ensure that the + * field will have the inline menu set up on it when it becomes visible. * - * @param element - The custom element to update the default styles for. + * @param formFieldElement - The form field element that triggered the focus event. + * @param autofillFieldData - Autofill field data captured from the form field element. */ - private updateCustomElementDefaultStyles(element: HTMLElement) { - this.unobserveCustomElements(); + private isHiddenField( + formFieldElement: ElementWithOpId, + autofillFieldData: AutofillField, + ): boolean { + if (!autofillFieldData.readonly && !autofillFieldData.disabled && autofillFieldData.viewable) { + this.removeHiddenFieldFallbackListener(formFieldElement); + return false; + } - setElementStyles(element, this.customElementDefaultStyles, true); - - this.observeCustomElements(); + this.setupHiddenFieldFallbackListener(formFieldElement, autofillFieldData); + return true; } /** - * Queries the background script for the autofill overlay visibility setting. + * Sets up a fallback listener that will facilitate setting up the + * inline menu on the field when it becomes visible and focused. + * + * @param formFieldElement - The form field element that triggered the focus event. + * @param autofillFieldData - Autofill field data captured from the form field element. + */ + private setupHiddenFieldFallbackListener( + formFieldElement: ElementWithOpId, + autofillFieldData: AutofillField, + ) { + this.hiddenFormFieldElements.set(formFieldElement, autofillFieldData); + formFieldElement.addEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent); + } + + /** + * Removes the fallback listener that facilitates setting up the inline + * menu on the field when it becomes visible and focused. + * + * @param formFieldElement - The form field element that triggered the focus event. + */ + private removeHiddenFieldFallbackListener(formFieldElement: ElementWithOpId) { + formFieldElement.removeEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent); + this.hiddenFormFieldElements.delete(formFieldElement); + } + + /** + * Handles the focus event on a hidden field. When + * triggered, the inline menu is set up on the field. + * + * @param event - The focus event. + */ + private handleHiddenFieldFocusEvent = (event: FocusEvent) => { + const formFieldElement = event.target as ElementWithOpId; + const autofillFieldData = this.hiddenFormFieldElements.get(formFieldElement); + if (autofillFieldData) { + autofillFieldData.readonly = getAttributeBoolean(formFieldElement, "disabled"); + autofillFieldData.disabled = getAttributeBoolean(formFieldElement, "disabled"); + autofillFieldData.viewable = true; + void this.setupInlineMenuOnQualifiedField(formFieldElement); + } + + this.removeHiddenFieldFallbackListener(formFieldElement); + }; + + /** + * Sets up the inline menu on a qualified form field element. + * + * @param formFieldElement - The form field element to set up the inline menu on. + */ + private async setupInlineMenuOnQualifiedField( + formFieldElement: ElementWithOpId, + ) { + this.formFieldElements.add(formFieldElement); + + if (!this.mostRecentlyFocusedField) { + await this.updateMostRecentlyFocusedField(formFieldElement); + } + + if (!this.inlineMenuVisibility) { + await this.getInlineMenuVisibility(); + } + + this.setupFormFieldElementEventListeners(formFieldElement); + + if ( + globalThis.document.hasFocus() && + this.getRootNodeActiveElement(formFieldElement) === formFieldElement + ) { + await this.triggerFormFieldFocusedAction(formFieldElement); + } + } + + /** + * Queries the background script for the autofill inline menu visibility setting. * If the setting is not found, a default value of OnFieldFocus will be used * @private */ - private async getAutofillOverlayVisibility() { - const overlayVisibility = await this.sendExtensionMessage("getAutofillOverlayVisibility"); - this.autofillOverlayVisibility = overlayVisibility || AutofillOverlayVisibility.OnFieldFocus; + private async getInlineMenuVisibility() { + const inlineMenuVisibility = await this.sendExtensionMessage("getAutofillInlineMenuVisibility"); + this.inlineMenuVisibility = inlineMenuVisibility || AutofillOverlayVisibility.OnFieldFocus; } /** - * Sets up event listeners that facilitate repositioning - * the autofill overlay on scroll or resize. - */ - private setOverlayRepositionEventListeners() { - globalThis.addEventListener(EVENTS.SCROLL, this.handleOverlayRepositionEvent, { - capture: true, - }); - globalThis.addEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent); - } - - /** - * Removes the listeners that facilitate repositioning - * the autofill overlay on scroll or resize. - */ - private removeOverlayRepositionEventListeners() { - globalThis.removeEventListener(EVENTS.SCROLL, this.handleOverlayRepositionEvent, { - capture: true, - }); - globalThis.removeEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent); - } - - /** - * Handles the resize or scroll events that enact - * repositioning of the overlay. - */ - private handleOverlayRepositionEvent = () => { - if (!this.isOverlayButtonVisible && !this.isOverlayListVisible) { - return; - } - - this.toggleOverlayHidden(true); - this.clearUserInteractionEventTimeout(); - this.userInteractionEventTimeout = setTimeout( - this.triggerOverlayRepositionUpdates, - 750, - ) as unknown as number; - }; - - /** - * Triggers the overlay reposition updates. This method ensures that the overlay elements - * are correctly positioned when the viewport scrolls or repositions. - */ - private triggerOverlayRepositionUpdates = async () => { - if (!this.recentlyFocusedFieldIsCurrentlyFocused()) { - this.toggleOverlayHidden(false); - this.removeAutofillOverlay(); - return; - } - - await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); - this.updateOverlayElementsPosition(); - this.toggleOverlayHidden(false); - this.clearUserInteractionEventTimeout(); - - if ( - this.focusedFieldData.focusedFieldRects?.top > 0 && - this.focusedFieldData.focusedFieldRects?.top < globalThis.innerHeight - ) { - return; - } - - this.removeAutofillOverlay(); - }; - - /** - * Clears the user interaction event timeout. This is used to ensure that - * the overlay is not repositioned while the user is interacting with it. - */ - private clearUserInteractionEventTimeout() { - if (this.userInteractionEventTimeout) { - clearTimeout(this.userInteractionEventTimeout); - } - } - - /** - * Sets up global event listeners and the mutation - * observer to facilitate required changes to the - * overlay elements. - */ - private setupGlobalEventListeners = () => { - globalThis.document.addEventListener(EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent); - globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); - this.setupMutationObserver(); - }; - - /** - * Handles the visibility change event. This method will remove the - * autofill overlay if the document is not visible. - */ - private handleVisibilityChangeEvent = () => { - if (document.visibilityState === "visible") { - return; - } - - this.mostRecentlyFocusedField = null; - this.removeAutofillOverlay(); - }; - - /** - * Sets up mutation observers for the overlay elements, the body element, and the - * document element. The mutation observers are used to remove any styles that are - * added to the overlay elements by the website. They are also used to ensure that - * the overlay elements are always present at the bottom of the body element. - */ - private setupMutationObserver = () => { - this.overlayElementsMutationObserver = new MutationObserver( - this.handleOverlayElementMutationObserverUpdate, - ); - - this.bodyElementMutationObserver = new MutationObserver( - this.handleBodyElementMutationObserverUpdate, - ); - }; - - /** - * Sets up mutation observers to verify that the overlay - * elements are not modified by the website. - */ - private observeCustomElements() { - if (this.overlayButtonElement) { - this.overlayElementsMutationObserver?.observe(this.overlayButtonElement, { - attributes: true, - }); - } - - if (this.overlayListElement) { - this.overlayElementsMutationObserver?.observe(this.overlayListElement, { attributes: true }); - } - } - - /** - * Disconnects the mutation observers that are used to verify that the overlay - * elements are not modified by the website. - */ - private unobserveCustomElements() { - this.overlayElementsMutationObserver?.disconnect(); - } - - /** - * Sets up a mutation observer for the body element. The mutation observer is used - * to ensure that the overlay elements are always present at the bottom of the body - * element. - */ - private observeBodyElement() { - this.bodyElementMutationObserver?.observe(globalThis.document.body, { childList: true }); - } - - /** - * Disconnects the mutation observer for the body element. - */ - private removeBodyElementObserver() { - this.bodyElementMutationObserver?.disconnect(); - } - - /** - * Handles the mutation observer update for the overlay elements. This method will - * remove any attributes or styles that might be added to the overlay elements by - * a separate process within the website where this script is injected. + * Returns a value that indicates if we should hide the inline menu list due to a filled field. * - * @param mutationRecord - The mutation record that triggered the update. + * @param formFieldElement - The form field element that triggered the focus event. */ - private handleOverlayElementMutationObserverUpdate = (mutationRecord: MutationRecord[]) => { - if (this.isTriggeringExcessiveMutationObserverIterations()) { - return; - } - - for (let recordIndex = 0; recordIndex < mutationRecord.length; recordIndex++) { - const record = mutationRecord[recordIndex]; - if (record.type !== "attributes") { - continue; - } - - const element = record.target as HTMLElement; - if (record.attributeName !== "style") { - this.removeModifiedElementAttributes(element); - - continue; - } - - element.removeAttribute("style"); - this.updateCustomElementDefaultStyles(element); - } - }; + private async hideInlineMenuListOnFilledField( + formFieldElement?: FillableFormFieldElement, + ): Promise { + return ( + formFieldElement?.value && + ((await this.isInlineMenuCiphersPopulated()) || !this.isUserAuthed()) + ); + } /** - * Removes all elements from a passed overlay - * element except for the style attribute. - * - * @param element - The element to remove the attributes from. + * Indicates whether the most recently focused field has a value. */ - private removeModifiedElementAttributes(element: HTMLElement) { - const attributes = Array.from(element.attributes); - for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) { - const attribute = attributes[attributeIndex]; - if (attribute.name === "style") { - continue; - } + private mostRecentlyFocusedFieldHasValue() { + return Boolean((this.mostRecentlyFocusedField as FillableFormFieldElement)?.value); + } - element.removeAttribute(attribute.name); + /** + * Updates the local reference to the inline menu visibility setting. + * + * @param data - The data object from the extension message. + */ + private updateInlineMenuVisibility({ data }: AutofillExtensionMessage) { + if (!isNaN(data?.inlineMenuVisibility)) { + this.inlineMenuVisibility = data.inlineMenuVisibility; } } /** - * Handles the mutation observer update for the body element. This method will - * ensure that the overlay elements are always present at the bottom of the body - * element. + * Checks if a field is currently filling within an frame in the tab. */ - private handleBodyElementMutationObserverUpdate = () => { - if ( - (!this.overlayButtonElement && !this.overlayListElement) || - this.isTriggeringExcessiveMutationObserverIterations() - ) { - return; - } - - const lastChild = globalThis.document.body.lastElementChild; - const secondToLastChild = lastChild?.previousElementSibling; - const lastChildIsOverlayList = lastChild === this.overlayListElement; - const lastChildIsOverlayButton = lastChild === this.overlayButtonElement; - const secondToLastChildIsOverlayButton = secondToLastChild === this.overlayButtonElement; - - if ( - (lastChildIsOverlayList && secondToLastChildIsOverlayButton) || - (lastChildIsOverlayButton && !this.isOverlayListVisible) - ) { - return; - } - - if ( - (lastChildIsOverlayList && !secondToLastChildIsOverlayButton) || - (lastChildIsOverlayButton && this.isOverlayListVisible) - ) { - globalThis.document.body.insertBefore(this.overlayButtonElement, this.overlayListElement); - return; - } - - globalThis.document.body.insertBefore(lastChild, this.overlayButtonElement); - }; + private async isFieldCurrentlyFilling() { + return (await this.sendExtensionMessage("checkIsFieldCurrentlyFilling")) === true; + } /** - * Identifies if the mutation observer is triggering excessive iterations. - * Will trigger a blur of the most recently focused field and remove the - * autofill overlay if any set mutation observer is triggering - * excessive iterations. + * Checks if the inline menu button is visible at the top frame. */ - private isTriggeringExcessiveMutationObserverIterations() { - if (this.mutationObserverIterationsResetTimeout) { - clearTimeout(this.mutationObserverIterationsResetTimeout); - } + private async isInlineMenuButtonVisible() { + return (await this.sendExtensionMessage("checkIsAutofillInlineMenuButtonVisible")) === true; + } - this.mutationObserverIterations++; - this.mutationObserverIterationsResetTimeout = setTimeout( - () => (this.mutationObserverIterations = 0), - 2000, - ); + /** + * Checks if the inline menu list if visible at the top frame. + */ + private async isInlineMenuListVisible() { + return (await this.sendExtensionMessage("checkIsAutofillInlineMenuListVisible")) === true; + } - if (this.mutationObserverIterations > 100) { - clearTimeout(this.mutationObserverIterationsResetTimeout); - this.mutationObserverIterations = 0; - this.blurMostRecentOverlayField(); - this.removeAutofillOverlay(); - - return true; - } - - return false; + /** + * Checks if the current tab contains ciphers that can be used to populate the inline menu. + */ + private async isInlineMenuCiphersPopulated() { + return (await this.sendExtensionMessage("checkIsInlineMenuCiphersPopulated")) === true; } /** @@ -1084,31 +824,394 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte * @param element - The element to get the root node active element for. */ private getRootNodeActiveElement(element: Element): Element { + if (!element) { + return null; + } + const documentRoot = element.getRootNode() as ShadowRoot | Document; return documentRoot?.activeElement; } + /** + * Queries all iframe elements within the document and returns the + * sub frame offsets for each iframe element. + * + * @param message - The message object from the extension. + */ + private async getSubFrameOffsets( + message: AutofillExtensionMessage, + ): Promise { + const { subFrameUrl } = message; + + const subFrameUrlVariations = this.getSubFrameUrlVariations(subFrameUrl); + if (!subFrameUrlVariations) { + return null; + } + + let iframeElement: HTMLIFrameElement | null = null; + const iframeElements = globalThis.document.getElementsByTagName("iframe"); + + for (let iframeIndex = 0; iframeIndex < iframeElements.length; iframeIndex++) { + const iframe = iframeElements[iframeIndex]; + if (!subFrameUrlVariations.has(iframe.src)) { + continue; + } + + if (iframeElement) { + return null; + } + + iframeElement = iframe; + } + + if (!iframeElement) { + return null; + } + + return this.calculateSubFrameOffsets(iframeElement, subFrameUrl); + } + + /** + * Returns a set of all possible URL variations for the sub frame URL. + * + * @param subFrameUrl - The URL of the sub frame. + */ + private getSubFrameUrlVariations(subFrameUrl: string) { + try { + const url = new URL(subFrameUrl, globalThis.location.href); + const pathAndHash = url.pathname + url.hash; + const pathAndSearch = url.pathname + url.search; + const pathSearchAndHash = pathAndSearch + url.hash; + const pathNameWithoutTrailingSlash = url.pathname.replace(/\/$/, ""); + const pathWithoutTrailingSlashAndHash = pathNameWithoutTrailingSlash + url.hash; + const pathWithoutTrailingSlashAndSearch = pathNameWithoutTrailingSlash + url.search; + const pathWithoutTrailingSlashSearchAndHash = pathWithoutTrailingSlashAndSearch + url.hash; + + return new Set([ + url.href, + url.href.replace(/\/$/, ""), + url.pathname, + pathAndHash, + pathAndSearch, + pathSearchAndHash, + pathNameWithoutTrailingSlash, + pathWithoutTrailingSlashAndHash, + pathWithoutTrailingSlashAndSearch, + pathWithoutTrailingSlashSearchAndHash, + url.hostname + url.pathname, + url.hostname + pathAndHash, + url.hostname + pathAndSearch, + url.hostname + pathSearchAndHash, + url.hostname + pathNameWithoutTrailingSlash, + url.hostname + pathWithoutTrailingSlashAndHash, + url.hostname + pathWithoutTrailingSlashAndSearch, + url.hostname + pathWithoutTrailingSlashSearchAndHash, + url.origin + url.pathname, + url.origin + pathAndHash, + url.origin + pathAndSearch, + url.origin + pathSearchAndHash, + url.origin + pathNameWithoutTrailingSlash, + url.origin + pathWithoutTrailingSlashAndHash, + url.origin + pathWithoutTrailingSlashAndSearch, + url.origin + pathWithoutTrailingSlashSearchAndHash, + ]); + } catch (_error) { + return null; + } + } + + /** + * Posts a message to the parent frame to calculate the sub frame offset of the current frame. + * + * @param message - The message object from the extension. + */ + private getSubFrameOffsetsFromWindowMessage(message: any) { + globalThis.parent.postMessage( + { + command: "calculateSubFramePositioning", + subFrameData: { + url: window.location.href, + frameId: message.subFrameId, + left: 0, + top: 0, + parentFrameIds: [0], + subFrameDepth: 0, + } as SubFrameDataFromWindowMessage, + }, + "*", + ); + } + + /** + * Calculates the bounding rect for the queried frame and returns the + * offset data for the sub frame. + * + * @param iframeElement - The iframe element to calculate the sub frame offsets for. + * @param subFrameUrl - The URL of the sub frame. + * @param frameId - The frame ID of the sub frame. + */ + private calculateSubFrameOffsets( + iframeElement: HTMLIFrameElement, + subFrameUrl?: string, + frameId?: number, + ): SubFrameOffsetData { + const iframeRect = iframeElement.getBoundingClientRect(); + const iframeStyles = globalThis.getComputedStyle(iframeElement); + const paddingLeft = parseInt(iframeStyles.getPropertyValue("padding-left")) || 0; + const paddingTop = parseInt(iframeStyles.getPropertyValue("padding-top")) || 0; + const borderWidthLeft = parseInt(iframeStyles.getPropertyValue("border-left-width")) || 0; + const borderWidthTop = parseInt(iframeStyles.getPropertyValue("border-top-width")) || 0; + + return { + url: subFrameUrl, + frameId, + top: iframeRect.top + paddingTop + borderWidthTop, + left: iframeRect.left + paddingLeft + borderWidthLeft, + }; + } + + /** + * Calculates the sub frame positioning for the current frame + * through all parent frames until the top frame is reached. + * + * @param event - The message event. + */ + private calculateSubFramePositioning = async (event: MessageEvent) => { + const subFrameData: SubFrameDataFromWindowMessage = event.data.subFrameData; + + subFrameData.subFrameDepth++; + if (subFrameData.subFrameDepth >= MAX_SUB_FRAME_DEPTH) { + void this.sendExtensionMessage("destroyAutofillInlineMenuListeners", { subFrameData }); + return; + } + + let subFrameOffsets: SubFrameOffsetData; + const iframes = globalThis.document.querySelectorAll("iframe"); + for (let i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === event.source) { + const iframeElement = iframes[i]; + subFrameOffsets = this.calculateSubFrameOffsets( + iframeElement, + subFrameData.url, + subFrameData.frameId, + ); + + subFrameData.top += subFrameOffsets.top; + subFrameData.left += subFrameOffsets.left; + + const parentFrameId = await this.sendExtensionMessage("getCurrentTabFrameId"); + if (typeof parentFrameId !== "undefined") { + subFrameData.parentFrameIds.push(parentFrameId); + } + + break; + } + } + + if (globalThis.window.self !== globalThis.window.top) { + globalThis.parent.postMessage({ command: "calculateSubFramePositioning", subFrameData }, "*"); + return; + } + + void this.sendExtensionMessage("updateSubFrameData", { subFrameData }); + }; + + /** + * Sets up global event listeners and the mutation + * observer to facilitate required changes to the + * overlay elements. + */ + private setupGlobalEventListeners = () => { + globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent); + globalThis.document.addEventListener(EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent); + globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); + this.setOverlayRepositionEventListeners(); + }; + + /** + * Handles window messages that are sent to the current frame. Will trigger a + * calculation of the sub frame offsets through the parent frame. + * + * @param event - The message event. + */ + private handleWindowMessageEvent = (event: MessageEvent) => { + if (event.data?.command === "calculateSubFramePositioning") { + void this.calculateSubFramePositioning(event); + } + }; + + /** + * Handles the visibility change event. This method will remove the + * autofill overlay if the document is not visible. + */ + private handleVisibilityChangeEvent = () => { + if (!this.mostRecentlyFocusedField || globalThis.document.visibilityState === "visible") { + return; + } + + this.unsetMostRecentlyFocusedField(); + void this.sendExtensionMessage("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }; + + /** + * Sets up event listeners that facilitate repositioning + * the overlay elements on scroll or resize. + */ + private setOverlayRepositionEventListeners() { + const handler = this.useEventHandlersMemo( + throttle(this.handleOverlayRepositionEvent, 250), + AUTOFILL_OVERLAY_HANDLE_REPOSITION, + ); + globalThis.addEventListener(EVENTS.SCROLL, handler, { + capture: true, + passive: true, + }); + globalThis.addEventListener(EVENTS.RESIZE, handler); + } + + /** + * Removes the listeners that facilitate repositioning + * the overlay elements on scroll or resize. + */ + private removeOverlayRepositionEventListeners() { + const handler = this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION]; + globalThis.removeEventListener(EVENTS.SCROLL, handler, { + capture: true, + }); + globalThis.removeEventListener(EVENTS.RESIZE, handler); + + delete this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION]; + } + + /** + * Handles the resize or scroll events that enact + * repositioning of existing overlay elements. + */ + private handleOverlayRepositionEvent = async () => { + await this.sendExtensionMessage("triggerAutofillOverlayReposition"); + }; + + /** + * Sets up listeners that facilitate a rebuild of the sub frame offsets + * when a user interacts or focuses an element within the frame. + */ + private setupRebuildSubFrameOffsetsListeners = () => { + if (globalThis.window.top === globalThis.window || this.formFieldElements.size < 1) { + return; + } + this.removeSubFrameFocusOutListeners(); + + globalThis.addEventListener(EVENTS.FOCUS, this.handleSubFrameFocusInEvent); + globalThis.document.body.addEventListener(EVENTS.MOUSEENTER, this.handleSubFrameFocusInEvent); + }; + + /** + * Removes the listeners that facilitate a rebuild of the sub frame offsets. + */ + private removeRebuildSubFrameOffsetsListeners = () => { + globalThis.removeEventListener(EVENTS.FOCUS, this.handleSubFrameFocusInEvent); + globalThis.document.body.removeEventListener( + EVENTS.MOUSEENTER, + this.handleSubFrameFocusInEvent, + ); + }; + + /** + * Re-establishes listeners that handle the sub frame offsets rebuild of the frame + * based on user interaction with the sub frame. + */ + private setupSubFrameFocusOutListeners = () => { + globalThis.addEventListener(EVENTS.BLUR, this.setupRebuildSubFrameOffsetsListeners); + globalThis.document.body.addEventListener( + EVENTS.MOUSELEAVE, + this.setupRebuildSubFrameOffsetsListeners, + ); + }; + + /** + * Removes the listeners that trigger when a user focuses away from the sub frame. + */ + private removeSubFrameFocusOutListeners = () => { + globalThis.removeEventListener(EVENTS.BLUR, this.setupRebuildSubFrameOffsetsListeners); + globalThis.document.body.removeEventListener( + EVENTS.MOUSELEAVE, + this.setupRebuildSubFrameOffsetsListeners, + ); + }; + + /** + * Sends a message to the background script to trigger a rebuild of the sub frame + * offsets. Will deregister the listeners to ensure that other focus and mouse + * events do not unnecessarily re-trigger a sub frame rebuild. + */ + private handleSubFrameFocusInEvent = () => { + void this.sendExtensionMessage("triggerSubFrameFocusInRebuild"); + + this.removeRebuildSubFrameOffsetsListeners(); + this.setupSubFrameFocusOutListeners(); + }; + + /** + * Triggers an update in the most recently focused field's data and returns + * whether the field is within the viewport bounds. If not within the bounds + * of the viewport, the inline menu will be closed. + */ + private async checkIsMostRecentlyFocusedFieldWithinViewport() { + await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); + + const focusedFieldRectsTop = this.focusedFieldData?.focusedFieldRects?.top; + const focusedFieldRectsBottom = + focusedFieldRectsTop + this.focusedFieldData?.focusedFieldRects?.height; + const viewportHeight = globalThis.innerHeight + globalThis.scrollY; + return ( + focusedFieldRectsTop && + focusedFieldRectsTop > 0 && + focusedFieldRectsTop < viewportHeight && + focusedFieldRectsBottom < viewportHeight + ); + } + + /** + * Clears the timeout that triggers a debounced focus of the inline menu list. + */ + private clearFocusInlineMenuListTimeout() { + if (this.focusInlineMenuListTimeout) { + globalThis.clearTimeout(this.focusInlineMenuListTimeout); + } + } + + /** + * Clears the timeout that triggers the closing of the inline menu on a focus redirection. + */ + private clearCloseInlineMenuOnRedirectTimeout() { + if (this.closeInlineMenuOnRedirectTimeout) { + globalThis.clearTimeout(this.closeInlineMenuOnRedirectTimeout); + } + } + /** * Destroys the autofill overlay content service. This method will * disconnect the mutation observers and remove all event listeners. */ destroy() { - this.documentElementMutationObserver?.disconnect(); - this.clearUserInteractionEventTimeout(); + this.clearFocusInlineMenuListTimeout(); + this.clearCloseInlineMenuOnRedirectTimeout(); this.formFieldElements.forEach((formFieldElement) => { this.removeCachedFormFieldEventListeners(formFieldElement); formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent); formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent); this.formFieldElements.delete(formFieldElement); }); + globalThis.removeEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent); globalThis.document.removeEventListener( EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent, ); globalThis.removeEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); - this.removeAutofillOverlay(); this.removeOverlayRepositionEventListeners(); + this.removeRebuildSubFrameOffsetsListeners(); + this.removeSubFrameFocusOutListeners(); } } - -export default AutofillOverlayContentService; diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index dc9f3fcdbd..ce7f4d41d2 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -5,7 +5,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; -import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DefaultDomainSettingsService, DomainSettingsService, @@ -14,6 +14,7 @@ import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; @@ -40,7 +41,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; -import { AutofillPort } from "../enums/autofill-port.enums"; +import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -72,7 +73,7 @@ describe("AutofillService", () => { let autofillService: AutofillService; const cipherService = mock(); let inlineMenuVisibilityMock$!: BehaviorSubject; - let autofillSettingsService: MockProxy; + let autofillSettingsService: MockProxy; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); @@ -86,16 +87,18 @@ describe("AutofillService", () => { const platformUtilsService = mock(); let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; + let configService: MockProxy; let messageListener: MockProxy; beforeEach(() => { scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService); inlineMenuVisibilityMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus); - autofillSettingsService = mock(); - (autofillSettingsService as any).inlineMenuVisibility$ = inlineMenuVisibilityMock$; + autofillSettingsService = mock(); + autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$; activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; + configService = mock(); messageListener = mock(); autofillService = new AutofillService( cipherService, @@ -109,6 +112,7 @@ describe("AutofillService", () => { scriptInjectorService, accountService, authService, + configService, messageListener, ); domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); @@ -213,7 +217,7 @@ describe("AutofillService", () => { .spyOn(BrowserApi, "getAllFrameDetails") .mockResolvedValue([mock({ frameId: 0 })]); jest - .spyOn(autofillService, "getOverlayVisibility") + .spyOn(autofillService, "getInlineMenuVisibility") .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true); }); @@ -275,13 +279,13 @@ describe("AutofillService", () => { expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab1, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnButtonClick }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick }, ); expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab2, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnButtonClick }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick }, ); }); @@ -292,13 +296,13 @@ describe("AutofillService", () => { expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab1, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnFieldFocus }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnFieldFocus }, ); expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab2, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnFieldFocus }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnFieldFocus }, ); }); }); @@ -351,11 +355,12 @@ describe("AutofillService", () => { let sender: chrome.runtime.MessageSender; beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); tabMock = createChromeTabMock(); sender = { tab: tabMock, frameId: 1 }; jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); jest - .spyOn(autofillService, "getOverlayVisibility") + .spyOn(autofillService, "getInlineMenuVisibility") .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true); }); @@ -413,7 +418,7 @@ describe("AutofillService", () => { it("will inject the bootstrap-autofill script if the user does not have the autofill overlay enabled", async () => { jest - .spyOn(autofillService, "getOverlayVisibility") + .spyOn(autofillService, "getInlineMenuVisibility") .mockResolvedValue(AutofillOverlayVisibility.Off); await autofillService.injectAutofillScripts(sender.tab, sender.frameId); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 4c37cd1f07..81a47b2f61 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -12,10 +12,12 @@ import { DomainSettingsService } from "@bitwarden/common/autofill/services/domai import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategySetting, UriMatchStrategy, } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,7 +31,7 @@ import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; -import { AutofillPort } from "../enums/autofill-port.enums"; +import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -67,6 +69,7 @@ export default class AutofillService implements AutofillServiceInterface { private scriptInjectorService: ScriptInjectorService, private accountService: AccountService, private authService: AuthService, + private configService: ConfigService, private messageListener: MessageListener, ) {} @@ -160,16 +163,23 @@ export default class AutofillService implements AutofillServiceInterface { const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); const accountIsUnlocked = authStatus === AuthenticationStatus.Unlocked; - let overlayVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off; + let inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off; let autoFillOnPageLoadIsEnabled = false; if (activeAccount) { - overlayVisibility = await this.getOverlayVisibility(); + inlineMenuVisibility = await this.getInlineMenuVisibility(); } - const mainAutofillScript = overlayVisibility - ? "bootstrap-autofill-overlay.js" - : "bootstrap-autofill.js"; + let mainAutofillScript = "bootstrap-autofill.js"; + + if (inlineMenuVisibility) { + const inlineMenuPositioningImprovements = await this.configService.getFeatureFlag( + FeatureFlag.InlineMenuPositioningImprovements, + ); + mainAutofillScript = inlineMenuPositioningImprovements + ? "bootstrap-autofill-overlay.js" + : "bootstrap-legacy-autofill-overlay.js"; + } const injectedScripts = [mainAutofillScript]; @@ -274,7 +284,7 @@ export default class AutofillService implements AutofillServiceInterface { /** * Gets the overlay's visibility setting from the autofill settings service. */ - async getOverlayVisibility(): Promise { + async getInlineMenuVisibility(): Promise { return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); } @@ -2162,8 +2172,8 @@ export default class AutofillService implements AutofillServiceInterface { if (!inlineMenuPreviouslyDisabled && !inlineMenuCurrentlyDisabled) { const tabs = await BrowserApi.tabsQuery({}); tabs.forEach((tab) => - BrowserApi.tabSendMessageData(tab, "updateAutofillOverlayVisibility", { - autofillOverlayVisibility: currentSetting, + BrowserApi.tabSendMessageData(tab, "updateAutofillInlineMenuVisibility", { + inlineMenuVisibility: currentSetting, }), ); return; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 9bb0e717a2..f67c0e88aa 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -11,7 +11,8 @@ import { FormElementWithAttribute, } from "../types"; -import AutofillOverlayContentService from "./autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; +import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; import CollectAutofillContentService from "./collect-autofill-content.service"; import DomElementVisibilityService from "./dom-element-visibility.service"; @@ -28,7 +29,10 @@ const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdl describe("CollectAutofillContentService", () => { const domElementVisibilityService = new DomElementVisibilityService(); - const autofillOverlayContentService = new AutofillOverlayContentService(); + const inlineMenuFieldQualificationService = mock(); + const autofillOverlayContentService = new AutofillOverlayContentService( + inlineMenuFieldQualificationService, + ); let collectAutofillContentService: CollectAutofillContentService; const mockIntersectionObserver = mock(); const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); @@ -250,7 +254,7 @@ describe("CollectAutofillContentService", () => { .mockResolvedValue(true); const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( collectAutofillContentService["autofillOverlayContentService"], - "setupAutofillOverlayListenerOnField", + "setupInlineMenu", ); await collectAutofillContentService.getPageDetails(); @@ -2564,7 +2568,7 @@ describe("CollectAutofillContentService", () => { ); setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( collectAutofillContentService["autofillOverlayContentService"], - "setupAutofillOverlayListenerOnField", + "setupInlineMenu", ); }); @@ -2585,9 +2589,11 @@ describe("CollectAutofillContentService", () => { it("skips setting up the overlay listeners on a field that is not viewable", async () => { const formFieldElement = document.createElement("input") as ElementWithOpId; + const autofillField = mock(); const entries = [ { target: formFieldElement, isIntersecting: true }, ] as unknown as IntersectionObserverEntry[]; + collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField); isFormFieldViewableSpy.mockReturnValueOnce(false); await collectAutofillContentService["handleFormElementIntersection"](entries); @@ -2596,7 +2602,21 @@ describe("CollectAutofillContentService", () => { expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); }); - it("sets up the overlay listeners on a viewable field", async () => { + it("skips setting up the inline menu listeners if the observed form field is not present in the cache", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + isFormFieldViewableSpy.mockReturnValueOnce(true); + collectAutofillContentService["intersectionObserver"] = mockIntersectionObserver; + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).not.toHaveBeenCalled(); + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("sets up the inline menu listeners on a viewable field", async () => { const formFieldElement = document.createElement("input") as ElementWithOpId; const autofillField = mock(); const entries = [ @@ -2616,4 +2636,17 @@ describe("CollectAutofillContentService", () => { ); }); }); + + describe("destroy", () => { + it("clears the updateAfterMutationIdleCallback", () => { + jest.spyOn(window, "clearTimeout"); + collectAutofillContentService["updateAfterMutationIdleCallback"] = setTimeout(jest.fn, 100); + + collectAutofillContentService.destroy(); + + expect(clearTimeout).toHaveBeenCalledWith( + collectAutofillContentService["updateAfterMutationIdleCallback"], + ); + }); + }); }); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 75c564e868..b5541ba5eb 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,12 +1,7 @@ import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; -import { - ElementWithOpId, - FillableFormFieldElement, - FormElementWithAttribute, - FormFieldElement, -} from "../types"; +import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { elementIsDescriptionDetailsElement, elementIsDescriptionTermElement, @@ -21,6 +16,8 @@ import { nodeIsFormElement, nodeIsInputElement, // sendExtensionMessage, + getAttributeBoolean, + getPropertyOrAttribute, requestIdleCallbackPolyfill, cancelIdleCallbackPolyfill, } from "../utils"; @@ -37,6 +34,8 @@ import { DomElementVisibilityService } from "./abstractions/dom-element-visibili class CollectAutofillContentService implements CollectAutofillContentServiceInterface { private readonly domElementVisibilityService: DomElementVisibilityService; private readonly autofillOverlayContentService: AutofillOverlayContentService; + private readonly getAttributeBoolean = getAttributeBoolean; + private readonly getPropertyOrAttribute = getPropertyOrAttribute; private noFieldsFound = false; private domRecentlyMutated = true; private autofillFormElements: AutofillFormElements = new Map(); @@ -286,7 +285,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element); if (!previouslyViewable && autofillField.viewable) { - this.setupInlineMenuListenerOnField(element, autofillField); + this.setupInlineMenu(element, autofillField); } }); } @@ -537,26 +536,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte ); } - /** - * Returns a boolean representing the attribute value of an element. - * @param {ElementWithOpId} element - * @param {string} attributeName - * @param {boolean} checkString - * @returns {boolean} - * @private - */ - private getAttributeBoolean( - element: ElementWithOpId, - attributeName: string, - checkString = false, - ): boolean { - if (checkString) { - return this.getPropertyOrAttribute(element, attributeName) === "true"; - } - - return Boolean(this.getPropertyOrAttribute(element, attributeName)); - } - /** * Returns the attribute of an element as a lowercase value. * @param {ElementWithOpId} element @@ -868,21 +847,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return this.recursivelyGetTextFromPreviousSiblings(siblingElement); } - /** - * Get the value of a property or attribute from a FormFieldElement. - * @param {HTMLElement} element - * @param {string} attributeName - * @returns {string | null} - * @private - */ - private getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { - if (attributeName in element) { - return (element as FormElementWithAttribute)[attributeName]; - } - - return element.getAttribute(attributeName); - } - /** * Gets the value of the element. If the element is a checkbox, returns a checkmark if the * checkbox is checked, or an empty string if it is not checked. If the element is a hidden @@ -1411,20 +1375,20 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte continue; } + const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement); + if (!cachedAutofillFieldElement) { + this.intersectionObserver.unobserve(entry.target); + continue; + } + const isViewable = await this.domElementVisibilityService.isFormFieldViewable(formFieldElement); if (!isViewable) { continue; } - const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement); - if (!cachedAutofillFieldElement) { - continue; - } - cachedAutofillFieldElement.viewable = true; - - this.setupInlineMenuListenerOnField(formFieldElement, cachedAutofillFieldElement); + this.setupInlineMenu(formFieldElement, cachedAutofillFieldElement); this.intersectionObserver?.unobserve(entry.target); } @@ -1441,7 +1405,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte } this.autofillFieldElements.forEach((autofillField, formFieldElement) => { - this.setupInlineMenuListenerOnField(formFieldElement, autofillField, pageDetails); + this.setupInlineMenu(formFieldElement, autofillField, pageDetails); }); } @@ -1452,7 +1416,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @param autofillField - The metadata for the form field * @param pageDetails - The page details to use for the inline menu listeners */ - private setupInlineMenuListenerOnField( + private setupInlineMenu( formFieldElement: ElementWithOpId, autofillField: AutofillField, pageDetails?: AutofillPageDetails, @@ -1468,7 +1432,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte this.getFormattedAutofillFieldsData(), ); - void this.autofillOverlayContentService.setupAutofillOverlayListenerOnField( + void this.autofillOverlayContentService.setupInlineMenu( formFieldElement, autofillField, autofillPageDetails, diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index 127ce84d91..67986eb00f 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -1,3 +1,4 @@ +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; import { FillableFormFieldElement, FormFieldElement } from "../types"; import { DomElementVisibilityService as domElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service"; @@ -5,6 +6,8 @@ import { DomElementVisibilityService as domElementVisibilityServiceInterface } f class DomElementVisibilityService implements domElementVisibilityServiceInterface { private cachedComputedStyle: CSSStyleDeclaration | null = null; + constructor(private inlineMenuElements?: AutofillInlineMenuContentService) {} + /** * Checks if a form field is viewable. This is done by checking if the element is within the * viewport bounds, not hidden by CSS, and not hidden behind another element. @@ -187,6 +190,10 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac return true; } + if (this.inlineMenuElements?.isElementInlineMenu(elementAtCenterPoint as HTMLElement)) { + return true; + } + const targetElementLabelsSet = new Set((targetElement as FillableFormFieldElement).labels); if (targetElementLabelsSet.has(elementAtCenterPoint as HTMLLabelElement)) { return true; diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 7bc027b392..a6253dffac 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -2,11 +2,11 @@ import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import { sendExtensionMessage } from "../utils"; -import { InlineMenuFieldQualificationsService as InlineMenuFieldQualificationsServiceInterface } from "./abstractions/inline-menu-field-qualifications.service"; +import { InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface } from "./abstractions/inline-menu-field-qualifications.service"; import { AutoFillConstants } from "./autofill-constants"; export class InlineMenuFieldQualificationService - implements InlineMenuFieldQualificationsServiceInterface + implements InlineMenuFieldQualificationServiceInterface { private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); private excludedAutofillLoginTypesSet = new Set(AutoFillConstants.ExcludedAutofillLoginTypes); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 6ee5171e58..ff0e82d664 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -1,10 +1,13 @@ +import { mock } from "jest-mock-extended"; + import { EVENTS } from "@bitwarden/common/autofill/constants"; import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script"; import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; -import AutofillOverlayContentService from "./autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; +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 +67,11 @@ function setMockWindowLocation({ } describe("InsertAutofillContentService", () => { + const inlineMenuFieldQualificationService = mock(); const domElementVisibilityService = new DomElementVisibilityService(); - const autofillOverlayContentService = new AutofillOverlayContentService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + inlineMenuFieldQualificationService, + ); const collectAutofillContentService = new CollectAutofillContentService( domElementVisibilityService, autofillOverlayContentService, diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index 021b7719b2..2d4ffd7f21 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -7,16 +7,16 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { OverlayCipherData } from "../background/abstractions/overlay.background"; +import { InlineMenuCipherData } from "../background/abstractions/overlay.background"; import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript, { FillScript } from "../models/autofill-script"; -import { InitAutofillOverlayButtonMessage } from "../overlay/abstractions/autofill-overlay-button"; -import { InitAutofillOverlayListMessage } from "../overlay/abstractions/autofill-overlay-list"; +import { InitAutofillInlineMenuButtonMessage } from "../overlay/inline-menu/abstractions/autofill-inline-menu-button"; +import { InitAutofillInlineMenuListMessage } from "../overlay/inline-menu/abstractions/autofill-inline-menu-list"; import { GenerateFillScriptOptions, PageDetail } from "../services/abstractions/autofill.service"; -function createAutofillFormMock(customFields = {}): AutofillForm { +export function createAutofillFormMock(customFields = {}): AutofillForm { return { opid: "default-form-opid", htmlID: "default-htmlID", @@ -27,7 +27,7 @@ function createAutofillFormMock(customFields = {}): AutofillForm { }; } -function createAutofillFieldMock(customFields = {}): AutofillField { +export function createAutofillFieldMock(customFields = {}): AutofillField { return { opid: "default-input-field-opid", elementNumber: 0, @@ -57,7 +57,7 @@ function createAutofillFieldMock(customFields = {}): AutofillField { }; } -function createPageDetailMock(customFields = {}): PageDetail { +export function createPageDetailMock(customFields = {}): PageDetail { return { frameId: 0, tab: createChromeTabMock(), @@ -66,7 +66,7 @@ function createPageDetailMock(customFields = {}): PageDetail { }; } -function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { +export function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { return { title: "title", url: "url", @@ -86,7 +86,7 @@ function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { }; } -function createChromeTabMock(customFields = {}): chrome.tabs.Tab { +export function createChromeTabMock(customFields = {}): chrome.tabs.Tab { return { id: 1, index: 1, @@ -104,7 +104,7 @@ function createChromeTabMock(customFields = {}): chrome.tabs.Tab { }; } -function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScriptOptions { +export function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScriptOptions { return { skipUsernameOnlyFill: false, onlyEmptyFields: false, @@ -118,7 +118,7 @@ function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScr }; } -function createAutofillScriptMock( +export function createAutofillScriptMock( customFields = {}, scriptTypes?: Record, ): AutofillScript { @@ -159,24 +159,28 @@ const overlayPagesTranslations = { unlockYourAccount: "unlockYourAccount", unlockAccount: "unlockAccount", fillCredentialsFor: "fillCredentialsFor", - partialUsername: "partialUsername", + username: "username", view: "view", noItemsToShow: "noItemsToShow", newItem: "newItem", addNewVaultItem: "addNewVaultItem", }; -function createInitAutofillOverlayButtonMessageMock( +export function createInitAutofillInlineMenuButtonMessageMock( customFields = {}, -): InitAutofillOverlayButtonMessage { +): InitAutofillInlineMenuButtonMessage { return { - command: "initAutofillOverlayButton", + command: "initAutofillInlineMenuButton", translations: overlayPagesTranslations, styleSheetUrl: "https://jest-testing-website.com", authStatus: AuthenticationStatus.Unlocked, + portKey: "portKey", ...customFields, }; } -function createAutofillOverlayCipherDataMock(index: number, customFields = {}): OverlayCipherData { +export function createAutofillOverlayCipherDataMock( + index: number, + customFields = {}, +): InlineMenuCipherData { return { id: String(index), name: `website login ${index}`, @@ -194,15 +198,16 @@ function createAutofillOverlayCipherDataMock(index: number, customFields = {}): }; } -function createInitAutofillOverlayListMessageMock( +export function createInitAutofillInlineMenuListMessageMock( customFields = {}, -): InitAutofillOverlayListMessage { +): InitAutofillInlineMenuListMessage { return { - command: "initAutofillOverlayList", + command: "initAutofillInlineMenuList", translations: overlayPagesTranslations, styleSheetUrl: "https://jest-testing-website.com", theme: ThemeType.Light, authStatus: AuthenticationStatus.Unlocked, + portKey: "portKey", ciphers: [ createAutofillOverlayCipherDataMock(1, { icon: { @@ -237,7 +242,7 @@ function createInitAutofillOverlayListMessageMock( }; } -function createFocusedFieldDataMock(customFields = {}) { +export function createFocusedFieldDataMock(customFields = {}) { return { focusedFieldRects: { top: 1, @@ -250,11 +255,12 @@ function createFocusedFieldDataMock(customFields = {}) { paddingLeft: "6px", }, tabId: 1, + frameId: 2, ...customFields, }; } -function createPortSpyMock(name: string) { +export function createPortSpyMock(name: string) { return mock({ name, onMessage: { @@ -273,16 +279,17 @@ function createPortSpyMock(name: string) { }); } -export { - createAutofillFormMock, - createAutofillFieldMock, - createPageDetailMock, - createAutofillPageDetailsMock, - createChromeTabMock, - createGenerateFillScriptOptionsMock, - createAutofillScriptMock, - createInitAutofillOverlayButtonMessageMock, - createInitAutofillOverlayListMessageMock, - createFocusedFieldDataMock, - createPortSpyMock, -}; +export function createMutationRecordMock(customFields = {}): MutationRecord { + return { + addedNodes: mock(), + attributeName: "default-attributeName", + attributeNamespace: "default-attributeNamespace", + nextSibling: null, + oldValue: "default-oldValue", + previousSibling: null, + removedNodes: mock(), + target: null, + type: "attributes", + ...customFields, + }; +} diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index ba7a584498..1cef518602 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -48,6 +48,22 @@ export function sendPortMessage(port: chrome.runtime.Port, message: any) { }); } +export function triggerPortOnConnectEvent(port: chrome.runtime.Port) { + (chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(port); + }, + ); +} + +export function triggerPortOnMessageEvent(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); + }); +} + export function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { (port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; @@ -105,6 +121,17 @@ export function triggerOnAlarmEvent(alarm: chrome.alarms.Alarm) { }); } +export function triggerWebNavigationOnCommittedEvent( + details: chrome.webNavigation.WebNavigationFramedCallbackDetails, +) { + (chrome.webNavigation.onCommitted.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(details); + }, + ); +} + export function mockQuerySelectorAllDefinedCall() { const originalDocumentQuerySelectorAll = document.querySelectorAll; document.querySelectorAll = function (selector: string) { diff --git a/apps/browser/src/autofill/utils/autofill-overlay.enum.ts b/apps/browser/src/autofill/utils/autofill-overlay.enum.ts deleted file mode 100644 index 486d68f754..0000000000 --- a/apps/browser/src/autofill/utils/autofill-overlay.enum.ts +++ /dev/null @@ -1,17 +0,0 @@ -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; - -export { AutofillOverlayElement, AutofillOverlayPort, RedirectFocusDirection }; diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index dcb5aa6469..116df044b3 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -1,4 +1,4 @@ -import { AutofillPort } from "../enums/autofill-port.enums"; +import { AutofillPort } from "../enums/autofill-port.enum"; import { triggerPortOnDisconnectEvent } from "../spec/testing-utils"; import { logoIcon, logoLockedIcon } from "./svg-icons"; @@ -38,9 +38,7 @@ describe("generateRandomCustomElementName", () => { describe("sendExtensionMessage", () => { it("sends a message to the extension", async () => { - const extensionMessagePromise = sendExtensionMessage("updateAutofillOverlayHidden", { - display: "none", - }); + const extensionMessagePromise = sendExtensionMessage("some-extension-message"); // Jest doesn't give anyway to select the typed overload of "sendMessage", // a cast is needed to get the correct spy type. diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 873012d1db..a040fa5012 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -1,5 +1,24 @@ -import { AutofillPort } from "../enums/autofill-port.enums"; -import { FillableFormFieldElement, FormFieldElement } from "../types"; +import { AutofillPort } from "../enums/autofill-port.enum"; +import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; + +/** + * Generates a random string of characters. + * + * @param length - The length of the random string to generate. + */ +export function 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(""); +} /** * Polyfills the requestIdleCallback API with a setTimeout fallback. @@ -34,21 +53,7 @@ export function cancelIdleCallbackPolyfill(id: NodeJS.Timeout | number) { /** * 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(""); - }; - +export function generateRandomCustomElementName(): string { 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 @@ -81,7 +86,7 @@ function generateRandomCustomElementName(): 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 { +export function buildSvgDomElement(svgString: string, ariaHidden = true): HTMLElement { const domParser = new DOMParser(); const svgDom = domParser.parseFromString(svgString, "image/svg+xml"); const domElement = svgDom.documentElement; @@ -96,14 +101,14 @@ function buildSvgDomElement(svgString: string, ariaHidden = true): HTMLElement { * @param command - The command to send. * @param options - The options to send with the command. */ -async function sendExtensionMessage( +export async function sendExtensionMessage( command: string, options: Record = {}, ): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(Object.assign({ command }, options), (response) => { if (chrome.runtime.lastError) { - return; + // Do nothing } resolve(response); @@ -118,7 +123,7 @@ async function sendExtensionMessage( * @param styles - The styles to set on the element. * @param priority - Determines whether the styles should be set as important. */ -function setElementStyles( +export function setElementStyles( element: HTMLElement, styles: Partial, priority?: boolean, @@ -141,9 +146,9 @@ function setElementStyles( * and triggers an onDisconnect event if the extension context * is invalidated. * - * @param callback - Callback function to run when the extension disconnects + * @param callback - Callback export function to run when the extension disconnects */ -function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) { +export function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) { const port = chrome.runtime.connect({ name: AutofillPort.InjectedScript }); const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => { callback(disconnectedPort); @@ -158,7 +163,7 @@ function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => * * @param windowContext - The global window context */ -function setupAutofillInitDisconnectAction(windowContext: Window) { +export function setupAutofillInitDisconnectAction(windowContext: Window) { if (!windowContext.bitwardenAutofillInit) { return; } @@ -176,10 +181,10 @@ function setupAutofillInitDisconnectAction(windowContext: Window) { * * @param formFieldElement - The form field element to check. */ -function elementIsFillableFormField( +export function elementIsFillableFormField( formFieldElement: FormFieldElement, ): formFieldElement is FillableFormFieldElement { - return formFieldElement?.tagName.toLowerCase() !== "span"; + return !elementIsSpanElement(formFieldElement); } /** @@ -188,8 +193,11 @@ function elementIsFillableFormField( * @param element - The element to check. * @param tagName - The tag name to check against. */ -function elementIsInstanceOf(element: Element, tagName: string): element is T { - return element?.tagName.toLowerCase() === tagName; +export function elementIsInstanceOf( + element: Element, + tagName: string, +): element is T { + return nodeIsElement(element) && element.tagName.toLowerCase() === tagName; } /** @@ -197,7 +205,7 @@ function elementIsInstanceOf(element: Element, tagName: strin * * @param element - The element to check. */ -function elementIsSpanElement(element: Element): element is HTMLSpanElement { +export function elementIsSpanElement(element: Element): element is HTMLSpanElement { return elementIsInstanceOf(element, "span"); } @@ -206,7 +214,7 @@ function elementIsSpanElement(element: Element): element is HTMLSpanElement { * * @param element - The element to check. */ -function elementIsInputElement(element: Element): element is HTMLInputElement { +export function elementIsInputElement(element: Element): element is HTMLInputElement { return elementIsInstanceOf(element, "input"); } @@ -215,7 +223,7 @@ function elementIsInputElement(element: Element): element is HTMLInputElement { * * @param element - The element to check. */ -function elementIsSelectElement(element: Element): element is HTMLSelectElement { +export function elementIsSelectElement(element: Element): element is HTMLSelectElement { return elementIsInstanceOf(element, "select"); } @@ -224,7 +232,7 @@ function elementIsSelectElement(element: Element): element is HTMLSelectElement * * @param element - The element to check. */ -function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElement { +export function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElement { return elementIsInstanceOf(element, "textarea"); } @@ -233,7 +241,7 @@ function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElem * * @param element - The element to check. */ -function elementIsFormElement(element: Element): element is HTMLFormElement { +export function elementIsFormElement(element: Element): element is HTMLFormElement { return elementIsInstanceOf(element, "form"); } @@ -242,7 +250,7 @@ function elementIsFormElement(element: Element): element is HTMLFormElement { * * @param element - The element to check. */ -function elementIsLabelElement(element: Element): element is HTMLLabelElement { +export function elementIsLabelElement(element: Element): element is HTMLLabelElement { return elementIsInstanceOf(element, "label"); } @@ -251,7 +259,7 @@ function elementIsLabelElement(element: Element): element is HTMLLabelElement { * * @param element - The element to check. */ -function elementIsDescriptionDetailsElement(element: Element): element is HTMLElement { +export function elementIsDescriptionDetailsElement(element: Element): element is HTMLElement { return elementIsInstanceOf(element, "dd"); } @@ -260,7 +268,7 @@ function elementIsDescriptionDetailsElement(element: Element): element is HTMLEl * * @param element - The element to check. */ -function elementIsDescriptionTermElement(element: Element): element is HTMLElement { +export function elementIsDescriptionTermElement(element: Element): element is HTMLElement { return elementIsInstanceOf(element, "dt"); } @@ -269,12 +277,12 @@ function elementIsDescriptionTermElement(element: Element): element is HTMLEleme * * @param node - The node to check. */ -function nodeIsElement(node: Node): node is Element { +export function nodeIsElement(node: Node): node is Element { if (!node) { return false; } - return node.nodeType === Node.ELEMENT_NODE; + return node?.nodeType === Node.ELEMENT_NODE; } /** @@ -282,7 +290,7 @@ function nodeIsElement(node: Node): node is Element { * * @param node - The node to check. */ -function nodeIsInputElement(node: Node): node is HTMLInputElement { +export function nodeIsInputElement(node: Node): node is HTMLInputElement { return nodeIsElement(node) && elementIsInputElement(node); } @@ -291,28 +299,56 @@ function nodeIsInputElement(node: Node): node is HTMLInputElement { * * @param node - The node to check. */ -function nodeIsFormElement(node: Node): node is HTMLFormElement { +export function nodeIsFormElement(node: Node): node is HTMLFormElement { return nodeIsElement(node) && elementIsFormElement(node); } -export { - generateRandomCustomElementName, - buildSvgDomElement, - sendExtensionMessage, - setElementStyles, - setupExtensionDisconnectAction, - setupAutofillInitDisconnectAction, - elementIsFillableFormField, - elementIsInstanceOf, - elementIsSpanElement, - elementIsInputElement, - elementIsSelectElement, - elementIsTextAreaElement, - elementIsFormElement, - elementIsLabelElement, - elementIsDescriptionDetailsElement, - elementIsDescriptionTermElement, - nodeIsElement, - nodeIsInputElement, - nodeIsFormElement, -}; +/** + * Returns a boolean representing the attribute value of an element. + * + * @param element + * @param attributeName + * @param checkString + */ +export function getAttributeBoolean( + element: HTMLElement, + attributeName: string, + checkString = false, +): boolean { + if (checkString) { + return getPropertyOrAttribute(element, attributeName) === "true"; + } + + return Boolean(getPropertyOrAttribute(element, attributeName)); +} + +/** + * Get the value of a property or attribute from a FormFieldElement. + * + * @param element + * @param attributeName + */ +export function getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { + if (attributeName in element) { + return (element as FormElementWithAttribute)[attributeName]; + } + + return element.getAttribute(attributeName); +} + +/** + * Throttles a callback function to run at most once every `limit` milliseconds. + * + * @param callback - The callback function to throttle. + * @param limit - The time in milliseconds to throttle the callback. + */ +export function throttle(callback: () => void, limit: number) { + let waitingDelay = false; + return function (...args: unknown[]) { + if (!waitingDelay) { + callback.apply(this, args); + waitingDelay = true; + globalThis.setTimeout(() => (waitingDelay = false), limit); + } + }; +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 35e674cfd1..9aac8464ab 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -72,6 +72,7 @@ import { import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -197,14 +198,16 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background"; import ContextMenusBackground from "../autofill/background/context-menus.background"; import NotificationBackground from "../autofill/background/notification.background"; -import OverlayBackground from "../autofill/background/overlay.background"; +import { OverlayBackground } from "../autofill/background/overlay.background"; import TabsBackground from "../autofill/background/tabs.background"; import WebRequestBackground from "../autofill/background/web-request.background"; import { CipherContextMenuHandler } from "../autofill/browser/cipher-context-menu-handler"; import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clicked-handler"; import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-handler"; +import LegacyOverlayBackground from "../autofill/deprecated/background/overlay.background.deprecated"; import { Fido2Background as Fido2BackgroundAbstraction } from "../autofill/fido2/background/abstractions/fido2.background"; import { Fido2Background } from "../autofill/fido2/background/fido2.background"; import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service"; @@ -351,7 +354,7 @@ export default class MainBackground { private contextMenusBackground: ContextMenusBackground; private idleBackground: IdleBackground; private notificationBackground: NotificationBackground; - private overlayBackground: OverlayBackground; + private overlayBackground: OverlayBackgroundInterface; private filelessImporterBackground: FilelessImporterBackground; private runtimeBackground: RuntimeBackground; private tabsBackground: TabsBackground; @@ -901,6 +904,7 @@ export default class MainBackground { this.scriptInjectorService, this.accountService, this.authService, + this.configService, messageListener, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); @@ -1052,17 +1056,7 @@ export default class MainBackground { themeStateService, this.configService, ); - this.overlayBackground = new OverlayBackground( - this.cipherService, - this.autofillService, - this.authService, - this.environmentService, - this.domainSettingsService, - this.autofillSettingsService, - this.i18nService, - this.platformUtilsService, - themeStateService, - ); + this.filelessImporterBackground = new FilelessImporterBackground( this.configService, this.authService, @@ -1072,11 +1066,6 @@ export default class MainBackground { this.syncService, this.scriptInjectorService, ); - this.tabsBackground = new TabsBackground( - this, - this.notificationBackground, - this.overlayBackground, - ); const contextMenuClickedHandler = new ContextMenuClickedHandler( (options) => this.platformUtilsService.copyToClipboard(options.text), @@ -1156,6 +1145,47 @@ export default class MainBackground { } this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService); + + this.configService + .getFeatureFlag(FeatureFlag.InlineMenuPositioningImprovements) + .then(async (enabled) => { + if (!enabled) { + this.overlayBackground = new LegacyOverlayBackground( + this.cipherService, + this.autofillService, + this.authService, + this.environmentService, + this.domainSettingsService, + this.autofillSettingsService, + this.i18nService, + this.platformUtilsService, + themeStateService, + ); + } else { + this.overlayBackground = new OverlayBackground( + this.logService, + this.cipherService, + this.autofillService, + this.authService, + this.environmentService, + this.domainSettingsService, + this.autofillSettingsService, + this.i18nService, + this.platformUtilsService, + themeStateService, + ); + } + + this.tabsBackground = new TabsBackground( + this, + this.notificationBackground, + this.overlayBackground, + ); + + await this.overlayBackground.init(); + await this.tabsBackground.init(); + }) + .catch((error) => this.logService.error(`Error initializing OverlayBackground: ${error}`)); } async bootstrap() { @@ -1192,8 +1222,6 @@ export default class MainBackground { await this.notificationBackground.init(); this.filelessImporterBackground.init(); await this.commandsBackground.init(); - await this.overlayBackground.init(); - await this.tabsBackground.init(); this.contextMenusBackground?.init(); await this.idleBackground.init(); this.webRequestBackground?.startListening(); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index b9ab9e0dd9..1979d70364 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -67,7 +67,12 @@ "optional_permissions": ["nativeMessaging", "privacy"], "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", "sandbox": { - "pages": ["overlay/button.html", "overlay/list.html"], + "pages": [ + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/button.html", + "overlay/list.html" + ], "content_security_policy": "sandbox allow-scripts; script-src 'self'" }, "commands": { @@ -107,6 +112,9 @@ "notification/bar.html", "images/icon38.png", "images/icon38_locked.png", + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/menu.html", "overlay/button.html", "overlay/list.html", "popup/fonts/*" diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index b9eac49764..c01117bfe1 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -73,7 +73,12 @@ "sandbox": "sandbox allow-scripts; script-src 'self'" }, "sandbox": { - "pages": ["overlay/button.html", "overlay/list.html"] + "pages": [ + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/button.html", + "overlay/list.html" + ] }, "commands": { "_execute_action": { @@ -113,6 +118,9 @@ "notification/bar.html", "images/icon38.png", "images/icon38_locked.png", + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/menu.html", "overlay/button.html", "overlay/list.html", "popup/fonts/*" diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c102f461a6..01470f4d11 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -42,6 +42,7 @@ import { } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -314,6 +315,7 @@ const safeProviders: SafeProvider[] = [ ScriptInjectorService, AccountServiceAbstraction, AuthService, + ConfigService, MessageListener, ], }), diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index eb1244bc26..e6ef80bcd9 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -106,12 +106,27 @@ const plugins = [ chunks: ["notification/bar"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/pages/button/button.html", + template: "./src/autofill/overlay/inline-menu/pages/button/button.html", + filename: "overlay/menu-button.html", + chunks: ["overlay/menu-button"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/list/list.html", + filename: "overlay/menu-list.html", + chunks: ["overlay/menu-list"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + filename: "overlay/menu.html", + chunks: ["overlay/menu"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/deprecated/overlay/pages/button/legacy-button.html", filename: "overlay/button.html", chunks: ["overlay/button"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/pages/list/list.html", + template: "./src/autofill/deprecated/overlay/pages/list/legacy-list.html", filename: "overlay/list.html", chunks: ["overlay/list"], }), @@ -161,6 +176,8 @@ const mainConfig = { "./src/autofill/content/trigger-autofill-script-injection.ts", "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", + "content/bootstrap-legacy-autofill-overlay": + "./src/autofill/deprecated/content/bootstrap-legacy-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", @@ -168,8 +185,16 @@ const mainConfig = { "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", "content/fido2-page-script": "./src/autofill/fido2/content/fido2-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", + "overlay/menu-button": + "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", + "overlay/menu-list": + "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", + "overlay/menu": + "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", + "overlay/button": + "./src/autofill/deprecated/overlay/pages/button/bootstrap-autofill-overlay-button.deprecated.ts", + "overlay/list": + "./src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts", "encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts", "content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts", "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 6d5af41a17..efbd089642 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -13,13 +13,16 @@ export const EVENTS = { BLUR: "blur", CLICK: "click", FOCUS: "focus", + FOCUSIN: "focusin", + FOCUSOUT: "focusout", SCROLL: "scroll", RESIZE: "resize", DOMCONTENTLOADED: "DOMContentLoaded", LOAD: "load", MESSAGE: "message", VISIBILITYCHANGE: "visibilitychange", - FOCUSOUT: "focusout", + MOUSEENTER: "mouseenter", + MOUSELEAVE: "mouseleave", } as const; export const ClearClipboardDelay = { @@ -51,6 +54,8 @@ export const SEPARATOR_ID = "separator"; export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds +export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-reposition-event"; + export const AutofillOverlayVisibility = { Off: 0, OnButtonClick: 1, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ba23b90cd2..fb4bd1f966 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -20,6 +20,7 @@ export enum FeatureFlag { MemberAccessReport = "ac-2059-member-access-report", TwoFactorComponentRefactor = "two-factor-component-refactor", EnableTimeThreshold = "PM-5864-dollar-threshold", + InlineMenuPositioningImprovements = "inline-menu-positioning-improvements", GroupsComponentRefactor = "groups-component-refactor", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", VaultBulkManagementAction = "vault-bulk-management-action", @@ -54,6 +55,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.MemberAccessReport]: FALSE, [FeatureFlag.TwoFactorComponentRefactor]: FALSE, [FeatureFlag.EnableTimeThreshold]: FALSE, + [FeatureFlag.InlineMenuPositioningImprovements]: FALSE, [FeatureFlag.GroupsComponentRefactor]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, [FeatureFlag.VaultBulkManagementAction]: FALSE,